1use std::collections::HashMap;
8
9use chrono::NaiveDate;
10use rust_decimal::Decimal;
11use rust_decimal_macros::dec;
12use serde::{Deserialize, Serialize};
13
14use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
22#[serde(rename_all = "snake_case")]
23pub enum EmissionScope {
24 #[default]
26 Scope1,
27 Scope2,
29 Scope3,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
35#[serde(rename_all = "snake_case")]
36pub enum Scope3Category {
37 #[default]
38 PurchasedGoods,
39 CapitalGoods,
40 FuelAndEnergy,
41 UpstreamTransport,
42 WasteGenerated,
43 BusinessTravel,
44 EmployeeCommuting,
45 UpstreamLeased,
46 DownstreamTransport,
47 ProcessingOfSoldProducts,
48 UseOfSoldProducts,
49 EndOfLifeTreatment,
50 DownstreamLeased,
51 Franchises,
52 Investments,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
57#[serde(rename_all = "snake_case")]
58pub enum EstimationMethod {
59 #[default]
61 ActivityBased,
62 SpendBased,
64 SupplierSpecific,
66 AverageData,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct EmissionRecord {
73 pub id: String,
74 pub entity_id: String,
75 pub scope: EmissionScope,
76 pub scope3_category: Option<Scope3Category>,
77 pub facility_id: Option<String>,
78 pub period: NaiveDate,
79 pub activity_data: Option<String>,
80 pub activity_unit: Option<String>,
81 #[serde(with = "rust_decimal::serde::str_option")]
82 pub emission_factor: Option<Decimal>,
83 #[serde(with = "rust_decimal::serde::str")]
84 pub co2e_tonnes: Decimal,
85 pub estimation_method: EstimationMethod,
86 pub source: Option<String>,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
95#[serde(rename_all = "snake_case")]
96pub enum EnergySourceType {
97 #[default]
98 Electricity,
99 NaturalGas,
100 Diesel,
101 Coal,
102 SolarPv,
103 WindOnshore,
104 Biomass,
105 Geothermal,
106}
107
108impl EnergySourceType {
109 pub fn is_renewable(&self) -> bool {
111 matches!(
112 self,
113 Self::SolarPv | Self::WindOnshore | Self::Biomass | Self::Geothermal
114 )
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct EnergyConsumption {
121 pub id: String,
122 pub entity_id: String,
123 pub facility_id: String,
124 pub period: NaiveDate,
125 pub energy_source: EnergySourceType,
126 #[serde(with = "rust_decimal::serde::str")]
127 pub consumption_kwh: Decimal,
128 #[serde(with = "rust_decimal::serde::str")]
129 pub cost: Decimal,
130 pub currency: String,
131 pub is_renewable: bool,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
140#[serde(rename_all = "snake_case")]
141pub enum WaterSource {
142 #[default]
143 Municipal,
144 Groundwater,
145 SurfaceWater,
146 Rainwater,
147 Recycled,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct WaterUsage {
153 pub id: String,
154 pub entity_id: String,
155 pub facility_id: String,
156 pub period: NaiveDate,
157 pub source: WaterSource,
158 #[serde(with = "rust_decimal::serde::str")]
159 pub withdrawal_m3: Decimal,
160 #[serde(with = "rust_decimal::serde::str")]
161 pub discharge_m3: Decimal,
162 #[serde(with = "rust_decimal::serde::str")]
163 pub consumption_m3: Decimal,
164 pub is_water_stressed_area: bool,
165}
166
167impl WaterUsage {
168 pub fn computed_consumption(&self) -> Decimal {
170 (self.withdrawal_m3 - self.discharge_m3).max(Decimal::ZERO)
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
180#[serde(rename_all = "snake_case")]
181pub enum WasteType {
182 #[default]
183 General,
184 Hazardous,
185 Electronic,
186 Organic,
187 Construction,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
192#[serde(rename_all = "snake_case")]
193pub enum DisposalMethod {
194 #[default]
195 Landfill,
196 Recycled,
197 Composted,
198 Incinerated,
199 Reused,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct WasteRecord {
205 pub id: String,
206 pub entity_id: String,
207 pub facility_id: String,
208 pub period: NaiveDate,
209 pub waste_type: WasteType,
210 pub disposal_method: DisposalMethod,
211 #[serde(with = "rust_decimal::serde::str")]
212 pub quantity_tonnes: Decimal,
213 pub is_diverted_from_landfill: bool,
214}
215
216impl WasteRecord {
217 pub fn computed_diversion(&self) -> bool {
219 !matches!(
220 self.disposal_method,
221 DisposalMethod::Landfill | DisposalMethod::Incinerated
222 )
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
232#[serde(rename_all = "snake_case")]
233pub enum DiversityDimension {
234 #[default]
235 Gender,
236 Ethnicity,
237 Age,
238 Disability,
239 VeteranStatus,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
244#[serde(rename_all = "snake_case")]
245pub enum OrganizationLevel {
246 #[default]
247 Corporate,
248 Department,
249 Team,
250 Executive,
251 Board,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct WorkforceDiversityMetric {
257 pub id: String,
258 pub entity_id: String,
259 pub period: NaiveDate,
260 pub dimension: DiversityDimension,
261 pub level: OrganizationLevel,
262 pub category: String,
263 pub headcount: u32,
264 pub total_headcount: u32,
265 #[serde(with = "rust_decimal::serde::str")]
266 pub percentage: Decimal,
267}
268
269impl WorkforceDiversityMetric {
270 pub fn computed_percentage(&self) -> Decimal {
272 if self.total_headcount == 0 {
273 return Decimal::ZERO;
274 }
275 (Decimal::from(self.headcount) / Decimal::from(self.total_headcount)).round_dp(4)
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct PayEquityMetric {
286 pub id: String,
287 pub entity_id: String,
288 pub period: NaiveDate,
289 pub dimension: DiversityDimension,
290 pub reference_group: String,
291 pub comparison_group: String,
292 #[serde(with = "rust_decimal::serde::str")]
293 pub reference_median_salary: Decimal,
294 #[serde(with = "rust_decimal::serde::str")]
295 pub comparison_median_salary: Decimal,
296 #[serde(with = "rust_decimal::serde::str")]
297 pub pay_gap_ratio: Decimal,
298 pub sample_size: u32,
299}
300
301impl PayEquityMetric {
302 pub fn computed_pay_gap_ratio(&self) -> Decimal {
304 if self.reference_median_salary.is_zero() {
305 return dec!(1.00);
306 }
307 (self.comparison_median_salary / self.reference_median_salary).round_dp(4)
308 }
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
317#[serde(rename_all = "snake_case")]
318pub enum IncidentType {
319 #[default]
320 Injury,
321 Illness,
322 NearMiss,
323 Fatality,
324 PropertyDamage,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct SafetyIncident {
330 pub id: String,
331 pub entity_id: String,
332 pub facility_id: String,
333 pub date: NaiveDate,
334 pub incident_type: IncidentType,
335 pub days_away: u32,
336 pub is_recordable: bool,
337 pub description: String,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct SafetyMetric {
343 pub id: String,
344 pub entity_id: String,
345 pub period: NaiveDate,
346 pub total_hours_worked: u64,
347 pub recordable_incidents: u32,
348 pub lost_time_incidents: u32,
349 pub days_away: u32,
350 pub near_misses: u32,
351 pub fatalities: u32,
352 #[serde(with = "rust_decimal::serde::str")]
353 pub trir: Decimal,
354 #[serde(with = "rust_decimal::serde::str")]
355 pub ltir: Decimal,
356 #[serde(with = "rust_decimal::serde::str")]
357 pub dart_rate: Decimal,
358}
359
360impl SafetyMetric {
361 pub fn computed_trir(&self) -> Decimal {
363 if self.total_hours_worked == 0 {
364 return Decimal::ZERO;
365 }
366 (Decimal::from(self.recordable_incidents) * dec!(200000)
367 / Decimal::from(self.total_hours_worked))
368 .round_dp(4)
369 }
370
371 pub fn computed_ltir(&self) -> Decimal {
373 if self.total_hours_worked == 0 {
374 return Decimal::ZERO;
375 }
376 (Decimal::from(self.lost_time_incidents) * dec!(200000)
377 / Decimal::from(self.total_hours_worked))
378 .round_dp(4)
379 }
380
381 pub fn computed_dart_rate(&self) -> Decimal {
383 if self.total_hours_worked == 0 {
384 return Decimal::ZERO;
385 }
386 (Decimal::from(self.days_away) * dec!(200000) / Decimal::from(self.total_hours_worked))
387 .round_dp(4)
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
397pub struct GovernanceMetric {
398 pub id: String,
399 pub entity_id: String,
400 pub period: NaiveDate,
401 pub board_size: u32,
402 pub independent_directors: u32,
403 pub female_directors: u32,
404 #[serde(with = "rust_decimal::serde::str")]
405 pub board_independence_ratio: Decimal,
406 #[serde(with = "rust_decimal::serde::str")]
407 pub board_gender_diversity_ratio: Decimal,
408 pub ethics_training_completion_pct: f64,
409 pub whistleblower_reports: u32,
410 pub anti_corruption_violations: u32,
411}
412
413impl GovernanceMetric {
414 pub fn computed_independence_ratio(&self) -> Decimal {
416 if self.board_size == 0 {
417 return Decimal::ZERO;
418 }
419 (Decimal::from(self.independent_directors) / Decimal::from(self.board_size)).round_dp(4)
420 }
421}
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
429#[serde(rename_all = "snake_case")]
430pub enum EsgRiskFlag {
431 #[default]
432 Low,
433 Medium,
434 High,
435 Critical,
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
440#[serde(rename_all = "snake_case")]
441pub enum AssessmentMethod {
442 #[default]
443 SelfAssessment,
444 ThirdPartyAudit,
445 OnSiteAssessment,
446 DocumentReview,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct SupplierEsgAssessment {
452 pub id: String,
453 pub entity_id: String,
454 pub vendor_id: String,
455 pub assessment_date: NaiveDate,
456 pub method: AssessmentMethod,
457 #[serde(with = "rust_decimal::serde::str")]
458 pub environmental_score: Decimal,
459 #[serde(with = "rust_decimal::serde::str")]
460 pub social_score: Decimal,
461 #[serde(with = "rust_decimal::serde::str")]
462 pub governance_score: Decimal,
463 #[serde(with = "rust_decimal::serde::str")]
464 pub overall_score: Decimal,
465 pub risk_flag: EsgRiskFlag,
466 pub corrective_actions_required: u32,
467}
468
469impl SupplierEsgAssessment {
470 pub fn computed_overall_score(&self) -> Decimal {
472 ((self.environmental_score + self.social_score + self.governance_score) / dec!(3))
473 .round_dp(2)
474 }
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
483#[serde(rename_all = "snake_case")]
484pub enum EsgFramework {
485 #[default]
487 Gri,
488 Esrs,
490 Sasb,
492 Tcfd,
494 Issb,
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
500#[serde(rename_all = "snake_case")]
501pub enum AssuranceLevel {
502 #[default]
504 None,
505 Limited,
507 Reasonable,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
513pub struct EsgDisclosure {
514 pub id: String,
515 pub entity_id: String,
516 pub reporting_period_start: NaiveDate,
517 pub reporting_period_end: NaiveDate,
518 pub framework: EsgFramework,
519 pub assurance_level: AssuranceLevel,
520 pub disclosure_topic: String,
521 pub metric_value: String,
522 pub metric_unit: String,
523 pub is_assured: bool,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize)]
528pub struct MaterialityAssessment {
529 pub id: String,
530 pub entity_id: String,
531 pub period: NaiveDate,
532 pub topic: String,
533 #[serde(with = "rust_decimal::serde::str")]
535 pub impact_score: Decimal,
536 #[serde(with = "rust_decimal::serde::str")]
538 pub financial_score: Decimal,
539 #[serde(with = "rust_decimal::serde::str")]
541 pub combined_score: Decimal,
542 pub is_material: bool,
543}
544
545impl MaterialityAssessment {
546 pub fn is_material_at_threshold(&self, threshold: Decimal) -> bool {
548 self.impact_score >= threshold || self.financial_score >= threshold
549 }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
554#[serde(rename_all = "snake_case")]
555pub enum ScenarioType {
556 #[default]
558 WellBelow2C,
559 Orderly,
561 Disorderly,
563 HotHouse,
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
569#[serde(rename_all = "snake_case")]
570pub enum TimeHorizon {
571 Short,
573 #[default]
575 Medium,
576 Long,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct ClimateScenario {
583 pub id: String,
584 pub entity_id: String,
585 pub scenario_type: ScenarioType,
586 pub time_horizon: TimeHorizon,
587 pub description: String,
588 #[serde(with = "rust_decimal::serde::str")]
589 pub temperature_rise_c: Decimal,
590 #[serde(with = "rust_decimal::serde::str")]
591 pub transition_risk_impact: Decimal,
592 #[serde(with = "rust_decimal::serde::str")]
593 pub physical_risk_impact: Decimal,
594 #[serde(with = "rust_decimal::serde::str")]
595 pub financial_impact: Decimal,
596}
597
598impl ToNodeProperties for EmissionRecord {
603 fn node_type_name(&self) -> &'static str {
604 "emission_record"
605 }
606 fn node_type_code(&self) -> u16 {
607 430
608 }
609 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
610 let mut p = HashMap::new();
611 p.insert(
612 "entityCode".into(),
613 GraphPropertyValue::String(self.entity_id.clone()),
614 );
615 p.insert(
616 "scope".into(),
617 GraphPropertyValue::String(format!("{:?}", self.scope)),
618 );
619 if let Some(ref cat) = self.scope3_category {
620 p.insert(
621 "scope3Category".into(),
622 GraphPropertyValue::String(format!("{:?}", cat)),
623 );
624 }
625 if let Some(ref fid) = self.facility_id {
626 p.insert("facilityId".into(), GraphPropertyValue::String(fid.clone()));
627 }
628 p.insert("period".into(), GraphPropertyValue::Date(self.period));
629 if let Some(ref ad) = self.activity_data {
630 p.insert(
631 "activityData".into(),
632 GraphPropertyValue::String(ad.clone()),
633 );
634 }
635 if let Some(ref au) = self.activity_unit {
636 p.insert(
637 "activityUnit".into(),
638 GraphPropertyValue::String(au.clone()),
639 );
640 }
641 if let Some(ef) = self.emission_factor {
642 p.insert("emissionFactor".into(), GraphPropertyValue::Decimal(ef));
643 }
644 p.insert(
645 "amount".into(),
646 GraphPropertyValue::Decimal(self.co2e_tonnes),
647 );
648 p.insert(
649 "dataQuality".into(),
650 GraphPropertyValue::String(format!("{:?}", self.estimation_method)),
651 );
652 if let Some(ref src) = self.source {
653 p.insert("source".into(), GraphPropertyValue::String(src.clone()));
654 }
655 p
656 }
657}
658
659impl ToNodeProperties for EnergyConsumption {
660 fn node_type_name(&self) -> &'static str {
661 "energy_consumption"
662 }
663 fn node_type_code(&self) -> u16 {
664 431
665 }
666 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
667 let mut p = HashMap::new();
668 p.insert(
669 "entityCode".into(),
670 GraphPropertyValue::String(self.entity_id.clone()),
671 );
672 p.insert(
673 "facilityId".into(),
674 GraphPropertyValue::String(self.facility_id.clone()),
675 );
676 p.insert("period".into(), GraphPropertyValue::Date(self.period));
677 p.insert(
678 "energySource".into(),
679 GraphPropertyValue::String(format!("{:?}", self.energy_source)),
680 );
681 p.insert(
682 "consumptionKwh".into(),
683 GraphPropertyValue::Decimal(self.consumption_kwh),
684 );
685 p.insert("cost".into(), GraphPropertyValue::Decimal(self.cost));
686 p.insert(
687 "currency".into(),
688 GraphPropertyValue::String(self.currency.clone()),
689 );
690 p.insert(
691 "isRenewable".into(),
692 GraphPropertyValue::Bool(self.is_renewable),
693 );
694 p
695 }
696}
697
698impl ToNodeProperties for WaterUsage {
699 fn node_type_name(&self) -> &'static str {
700 "water_usage"
701 }
702 fn node_type_code(&self) -> u16 {
703 432
704 }
705 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
706 let mut p = HashMap::new();
707 p.insert(
708 "entityCode".into(),
709 GraphPropertyValue::String(self.entity_id.clone()),
710 );
711 p.insert(
712 "facilityId".into(),
713 GraphPropertyValue::String(self.facility_id.clone()),
714 );
715 p.insert("period".into(), GraphPropertyValue::Date(self.period));
716 p.insert(
717 "source".into(),
718 GraphPropertyValue::String(format!("{:?}", self.source)),
719 );
720 p.insert(
721 "withdrawalM3".into(),
722 GraphPropertyValue::Decimal(self.withdrawal_m3),
723 );
724 p.insert(
725 "dischargeM3".into(),
726 GraphPropertyValue::Decimal(self.discharge_m3),
727 );
728 p.insert(
729 "consumptionM3".into(),
730 GraphPropertyValue::Decimal(self.consumption_m3),
731 );
732 p.insert(
733 "isWaterStressed".into(),
734 GraphPropertyValue::Bool(self.is_water_stressed_area),
735 );
736 p
737 }
738}
739
740impl ToNodeProperties for WasteRecord {
741 fn node_type_name(&self) -> &'static str {
742 "waste_record"
743 }
744 fn node_type_code(&self) -> u16 {
745 433
746 }
747 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
748 let mut p = HashMap::new();
749 p.insert(
750 "entityCode".into(),
751 GraphPropertyValue::String(self.entity_id.clone()),
752 );
753 p.insert(
754 "facilityId".into(),
755 GraphPropertyValue::String(self.facility_id.clone()),
756 );
757 p.insert("period".into(), GraphPropertyValue::Date(self.period));
758 p.insert(
759 "wasteType".into(),
760 GraphPropertyValue::String(format!("{:?}", self.waste_type)),
761 );
762 p.insert(
763 "disposalMethod".into(),
764 GraphPropertyValue::String(format!("{:?}", self.disposal_method)),
765 );
766 p.insert(
767 "quantityTonnes".into(),
768 GraphPropertyValue::Decimal(self.quantity_tonnes),
769 );
770 p.insert(
771 "isDivertedFromLandfill".into(),
772 GraphPropertyValue::Bool(self.is_diverted_from_landfill),
773 );
774 p
775 }
776}
777
778impl ToNodeProperties for WorkforceDiversityMetric {
779 fn node_type_name(&self) -> &'static str {
780 "workforce_diversity_metric"
781 }
782 fn node_type_code(&self) -> u16 {
783 434
784 }
785 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
786 let mut p = HashMap::new();
787 p.insert(
788 "entityCode".into(),
789 GraphPropertyValue::String(self.entity_id.clone()),
790 );
791 p.insert("period".into(), GraphPropertyValue::Date(self.period));
792 p.insert(
793 "dimension".into(),
794 GraphPropertyValue::String(format!("{:?}", self.dimension)),
795 );
796 p.insert(
797 "level".into(),
798 GraphPropertyValue::String(format!("{:?}", self.level)),
799 );
800 p.insert(
801 "category".into(),
802 GraphPropertyValue::String(self.category.clone()),
803 );
804 p.insert(
805 "headcount".into(),
806 GraphPropertyValue::Int(self.headcount as i64),
807 );
808 p.insert(
809 "totalHeadcount".into(),
810 GraphPropertyValue::Int(self.total_headcount as i64),
811 );
812 p.insert(
813 "percentage".into(),
814 GraphPropertyValue::Decimal(self.percentage),
815 );
816 p
817 }
818}
819
820impl ToNodeProperties for PayEquityMetric {
821 fn node_type_name(&self) -> &'static str {
822 "pay_equity_metric"
823 }
824 fn node_type_code(&self) -> u16 {
825 435
826 }
827 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
828 let mut p = HashMap::new();
829 p.insert(
830 "entityCode".into(),
831 GraphPropertyValue::String(self.entity_id.clone()),
832 );
833 p.insert("period".into(), GraphPropertyValue::Date(self.period));
834 p.insert(
835 "dimension".into(),
836 GraphPropertyValue::String(format!("{:?}", self.dimension)),
837 );
838 p.insert(
839 "referenceGroup".into(),
840 GraphPropertyValue::String(self.reference_group.clone()),
841 );
842 p.insert(
843 "comparisonGroup".into(),
844 GraphPropertyValue::String(self.comparison_group.clone()),
845 );
846 p.insert(
847 "referenceSalary".into(),
848 GraphPropertyValue::Decimal(self.reference_median_salary),
849 );
850 p.insert(
851 "comparisonSalary".into(),
852 GraphPropertyValue::Decimal(self.comparison_median_salary),
853 );
854 p.insert(
855 "payGapRatio".into(),
856 GraphPropertyValue::Decimal(self.pay_gap_ratio),
857 );
858 p.insert(
859 "sampleSize".into(),
860 GraphPropertyValue::Int(self.sample_size as i64),
861 );
862 p
863 }
864}
865
866impl ToNodeProperties for SafetyIncident {
867 fn node_type_name(&self) -> &'static str {
868 "safety_incident"
869 }
870 fn node_type_code(&self) -> u16 {
871 436
872 }
873 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
874 let mut p = HashMap::new();
875 p.insert(
876 "entityCode".into(),
877 GraphPropertyValue::String(self.entity_id.clone()),
878 );
879 p.insert(
880 "facilityId".into(),
881 GraphPropertyValue::String(self.facility_id.clone()),
882 );
883 p.insert("date".into(), GraphPropertyValue::Date(self.date));
884 p.insert(
885 "incidentType".into(),
886 GraphPropertyValue::String(format!("{:?}", self.incident_type)),
887 );
888 p.insert(
889 "daysAway".into(),
890 GraphPropertyValue::Int(self.days_away as i64),
891 );
892 p.insert(
893 "isRecordable".into(),
894 GraphPropertyValue::Bool(self.is_recordable),
895 );
896 p.insert(
897 "description".into(),
898 GraphPropertyValue::String(self.description.clone()),
899 );
900 p
901 }
902}
903
904impl ToNodeProperties for SafetyMetric {
905 fn node_type_name(&self) -> &'static str {
906 "safety_metric"
907 }
908 fn node_type_code(&self) -> u16 {
909 437
910 }
911 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
912 let mut p = HashMap::new();
913 p.insert(
914 "entityCode".into(),
915 GraphPropertyValue::String(self.entity_id.clone()),
916 );
917 p.insert("period".into(), GraphPropertyValue::Date(self.period));
918 p.insert(
919 "totalHoursWorked".into(),
920 GraphPropertyValue::Int(self.total_hours_worked as i64),
921 );
922 p.insert(
923 "recordableIncidents".into(),
924 GraphPropertyValue::Int(self.recordable_incidents as i64),
925 );
926 p.insert(
927 "lostTimeIncidents".into(),
928 GraphPropertyValue::Int(self.lost_time_incidents as i64),
929 );
930 p.insert(
931 "daysAway".into(),
932 GraphPropertyValue::Int(self.days_away as i64),
933 );
934 p.insert(
935 "nearMisses".into(),
936 GraphPropertyValue::Int(self.near_misses as i64),
937 );
938 p.insert(
939 "fatalities".into(),
940 GraphPropertyValue::Int(self.fatalities as i64),
941 );
942 p.insert("trir".into(), GraphPropertyValue::Decimal(self.trir));
943 p.insert("ltir".into(), GraphPropertyValue::Decimal(self.ltir));
944 p.insert(
945 "dartRate".into(),
946 GraphPropertyValue::Decimal(self.dart_rate),
947 );
948 p
949 }
950}
951
952impl ToNodeProperties for GovernanceMetric {
953 fn node_type_name(&self) -> &'static str {
954 "governance_metric"
955 }
956 fn node_type_code(&self) -> u16 {
957 438
958 }
959 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
960 let mut p = HashMap::new();
961 p.insert(
962 "entityCode".into(),
963 GraphPropertyValue::String(self.entity_id.clone()),
964 );
965 p.insert("period".into(), GraphPropertyValue::Date(self.period));
966 p.insert(
967 "boardSize".into(),
968 GraphPropertyValue::Int(self.board_size as i64),
969 );
970 p.insert(
971 "independentDirectors".into(),
972 GraphPropertyValue::Int(self.independent_directors as i64),
973 );
974 p.insert(
975 "femaleDirectors".into(),
976 GraphPropertyValue::Int(self.female_directors as i64),
977 );
978 p.insert(
979 "independenceRatio".into(),
980 GraphPropertyValue::Decimal(self.board_independence_ratio),
981 );
982 p.insert(
983 "genderDiversityRatio".into(),
984 GraphPropertyValue::Decimal(self.board_gender_diversity_ratio),
985 );
986 p.insert(
987 "ethicsTrainingPct".into(),
988 GraphPropertyValue::Float(self.ethics_training_completion_pct),
989 );
990 p.insert(
991 "whistleblowerReports".into(),
992 GraphPropertyValue::Int(self.whistleblower_reports as i64),
993 );
994 p.insert(
995 "antiCorruptionViolations".into(),
996 GraphPropertyValue::Int(self.anti_corruption_violations as i64),
997 );
998 p
999 }
1000}
1001
1002impl ToNodeProperties for SupplierEsgAssessment {
1003 fn node_type_name(&self) -> &'static str {
1004 "supplier_esg_assessment"
1005 }
1006 fn node_type_code(&self) -> u16 {
1007 439
1008 }
1009 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1010 let mut p = HashMap::new();
1011 p.insert(
1012 "entityCode".into(),
1013 GraphPropertyValue::String(self.entity_id.clone()),
1014 );
1015 p.insert(
1016 "vendorId".into(),
1017 GraphPropertyValue::String(self.vendor_id.clone()),
1018 );
1019 p.insert(
1020 "assessmentDate".into(),
1021 GraphPropertyValue::Date(self.assessment_date),
1022 );
1023 p.insert(
1024 "method".into(),
1025 GraphPropertyValue::String(format!("{:?}", self.method)),
1026 );
1027 p.insert(
1028 "environmentalScore".into(),
1029 GraphPropertyValue::Decimal(self.environmental_score),
1030 );
1031 p.insert(
1032 "socialScore".into(),
1033 GraphPropertyValue::Decimal(self.social_score),
1034 );
1035 p.insert(
1036 "governanceScore".into(),
1037 GraphPropertyValue::Decimal(self.governance_score),
1038 );
1039 p.insert(
1040 "overallScore".into(),
1041 GraphPropertyValue::Decimal(self.overall_score),
1042 );
1043 p.insert(
1044 "riskTier".into(),
1045 GraphPropertyValue::String(format!("{:?}", self.risk_flag)),
1046 );
1047 p.insert(
1048 "hasCorrectiveAction".into(),
1049 GraphPropertyValue::Bool(self.corrective_actions_required > 0),
1050 );
1051 p
1052 }
1053}
1054
1055impl ToNodeProperties for MaterialityAssessment {
1056 fn node_type_name(&self) -> &'static str {
1057 "materiality_assessment"
1058 }
1059 fn node_type_code(&self) -> u16 {
1060 440
1061 }
1062 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1063 let mut p = HashMap::new();
1064 p.insert(
1065 "entityCode".into(),
1066 GraphPropertyValue::String(self.entity_id.clone()),
1067 );
1068 p.insert("period".into(), GraphPropertyValue::Date(self.period));
1069 p.insert(
1070 "topic".into(),
1071 GraphPropertyValue::String(self.topic.clone()),
1072 );
1073 p.insert(
1074 "impactScore".into(),
1075 GraphPropertyValue::Decimal(self.impact_score),
1076 );
1077 p.insert(
1078 "financialScore".into(),
1079 GraphPropertyValue::Decimal(self.financial_score),
1080 );
1081 p.insert(
1082 "combinedScore".into(),
1083 GraphPropertyValue::Decimal(self.combined_score),
1084 );
1085 p.insert(
1086 "isMaterial".into(),
1087 GraphPropertyValue::Bool(self.is_material),
1088 );
1089 p
1090 }
1091}
1092
1093impl ToNodeProperties for EsgDisclosure {
1094 fn node_type_name(&self) -> &'static str {
1095 "esg_disclosure"
1096 }
1097 fn node_type_code(&self) -> u16 {
1098 441
1099 }
1100 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1101 let mut p = HashMap::new();
1102 p.insert(
1103 "entityCode".into(),
1104 GraphPropertyValue::String(self.entity_id.clone()),
1105 );
1106 p.insert(
1107 "framework".into(),
1108 GraphPropertyValue::String(format!("{:?}", self.framework)),
1109 );
1110 p.insert(
1111 "topic".into(),
1112 GraphPropertyValue::String(self.disclosure_topic.clone()),
1113 );
1114 p.insert(
1115 "periodStart".into(),
1116 GraphPropertyValue::Date(self.reporting_period_start),
1117 );
1118 p.insert(
1119 "periodEnd".into(),
1120 GraphPropertyValue::Date(self.reporting_period_end),
1121 );
1122 p.insert(
1123 "assuranceLevel".into(),
1124 GraphPropertyValue::String(format!("{:?}", self.assurance_level)),
1125 );
1126 p.insert(
1127 "metricValue".into(),
1128 GraphPropertyValue::String(self.metric_value.clone()),
1129 );
1130 p.insert(
1131 "metricUnit".into(),
1132 GraphPropertyValue::String(self.metric_unit.clone()),
1133 );
1134 p.insert(
1135 "isAssured".into(),
1136 GraphPropertyValue::Bool(self.is_assured),
1137 );
1138 p
1139 }
1140}
1141
1142impl ToNodeProperties for ClimateScenario {
1143 fn node_type_name(&self) -> &'static str {
1144 "climate_scenario"
1145 }
1146 fn node_type_code(&self) -> u16 {
1147 442
1148 }
1149 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
1150 let mut p = HashMap::new();
1151 p.insert(
1152 "entityCode".into(),
1153 GraphPropertyValue::String(self.entity_id.clone()),
1154 );
1155 p.insert(
1156 "scenario".into(),
1157 GraphPropertyValue::String(format!("{:?}", self.scenario_type)),
1158 );
1159 p.insert(
1160 "timeHorizon".into(),
1161 GraphPropertyValue::String(format!("{:?}", self.time_horizon)),
1162 );
1163 p.insert(
1164 "description".into(),
1165 GraphPropertyValue::String(self.description.clone()),
1166 );
1167 p.insert(
1168 "warmingPathway".into(),
1169 GraphPropertyValue::Decimal(self.temperature_rise_c),
1170 );
1171 p.insert(
1172 "transitionRisk".into(),
1173 GraphPropertyValue::Decimal(self.transition_risk_impact),
1174 );
1175 p.insert(
1176 "physicalRisk".into(),
1177 GraphPropertyValue::Decimal(self.physical_risk_impact),
1178 );
1179 p.insert(
1180 "financialImpact".into(),
1181 GraphPropertyValue::Decimal(self.financial_impact),
1182 );
1183 p
1184 }
1185}
1186
1187#[cfg(test)]
1192#[allow(clippy::unwrap_used)]
1193mod tests {
1194 use super::*;
1195
1196 fn d(s: &str) -> NaiveDate {
1197 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
1198 }
1199
1200 #[test]
1203 fn test_emission_record_serde_roundtrip() {
1204 let record = EmissionRecord {
1205 id: "EM-001".to_string(),
1206 entity_id: "C001".to_string(),
1207 scope: EmissionScope::Scope1,
1208 scope3_category: None,
1209 facility_id: Some("F-001".to_string()),
1210 period: d("2025-01-01"),
1211 activity_data: Some("100000 kWh".to_string()),
1212 activity_unit: Some("kWh".to_string()),
1213 emission_factor: Some(dec!(0.18)),
1214 co2e_tonnes: dec!(18),
1215 estimation_method: EstimationMethod::ActivityBased,
1216 source: Some("Natural gas combustion".to_string()),
1217 };
1218
1219 let json = serde_json::to_string(&record).unwrap();
1220 let deserialized: EmissionRecord = serde_json::from_str(&json).unwrap();
1221 assert_eq!(deserialized.co2e_tonnes, dec!(18));
1222 assert_eq!(deserialized.scope, EmissionScope::Scope1);
1223 }
1224
1225 #[test]
1226 fn test_emission_factor_calculation() {
1227 let consumption_kwh = dec!(100000);
1229 let factor = dec!(0.18); let co2e_kg = consumption_kwh * factor;
1231 let co2e_tonnes = co2e_kg / dec!(1000);
1232 assert_eq!(co2e_tonnes, dec!(18));
1233 }
1234
1235 #[test]
1238 fn test_energy_source_renewable() {
1239 assert!(EnergySourceType::SolarPv.is_renewable());
1240 assert!(EnergySourceType::WindOnshore.is_renewable());
1241 assert!(!EnergySourceType::NaturalGas.is_renewable());
1242 assert!(!EnergySourceType::Electricity.is_renewable());
1243 }
1244
1245 #[test]
1248 fn test_water_consumption_formula() {
1249 let usage = WaterUsage {
1250 id: "W-001".to_string(),
1251 entity_id: "C001".to_string(),
1252 facility_id: "F-001".to_string(),
1253 period: d("2025-01-01"),
1254 source: WaterSource::Municipal,
1255 withdrawal_m3: dec!(5000),
1256 discharge_m3: dec!(3500),
1257 consumption_m3: dec!(1500),
1258 is_water_stressed_area: false,
1259 };
1260
1261 assert_eq!(usage.computed_consumption(), dec!(1500));
1262 }
1263
1264 #[test]
1267 fn test_waste_diversion() {
1268 let recycled = WasteRecord {
1269 id: "WS-001".to_string(),
1270 entity_id: "C001".to_string(),
1271 facility_id: "F-001".to_string(),
1272 period: d("2025-01-01"),
1273 waste_type: WasteType::General,
1274 disposal_method: DisposalMethod::Recycled,
1275 quantity_tonnes: dec!(100),
1276 is_diverted_from_landfill: true,
1277 };
1278 assert!(recycled.computed_diversion());
1279
1280 let landfill = WasteRecord {
1281 disposal_method: DisposalMethod::Landfill,
1282 ..recycled.clone()
1283 };
1284 assert!(!landfill.computed_diversion());
1285 }
1286
1287 #[test]
1290 fn test_trir_formula() {
1291 let metric = SafetyMetric {
1292 id: "SM-001".to_string(),
1293 entity_id: "C001".to_string(),
1294 period: d("2025-01-01"),
1295 total_hours_worked: 1_000_000,
1296 recordable_incidents: 5,
1297 lost_time_incidents: 2,
1298 days_away: 30,
1299 near_misses: 15,
1300 fatalities: 0,
1301 trir: dec!(1.0000),
1302 ltir: dec!(0.4000),
1303 dart_rate: dec!(6.0000),
1304 };
1305
1306 assert_eq!(metric.computed_trir(), dec!(1.0000));
1308 assert_eq!(metric.computed_ltir(), dec!(0.4000));
1310 assert_eq!(metric.computed_dart_rate(), dec!(6.0000));
1312 }
1313
1314 #[test]
1315 fn test_trir_zero_hours() {
1316 let metric = SafetyMetric {
1317 id: "SM-002".to_string(),
1318 entity_id: "C001".to_string(),
1319 period: d("2025-01-01"),
1320 total_hours_worked: 0,
1321 recordable_incidents: 0,
1322 lost_time_incidents: 0,
1323 days_away: 0,
1324 near_misses: 0,
1325 fatalities: 0,
1326 trir: Decimal::ZERO,
1327 ltir: Decimal::ZERO,
1328 dart_rate: Decimal::ZERO,
1329 };
1330 assert_eq!(metric.computed_trir(), Decimal::ZERO);
1331 }
1332
1333 #[test]
1336 fn test_diversity_percentage() {
1337 let metric = WorkforceDiversityMetric {
1338 id: "WD-001".to_string(),
1339 entity_id: "C001".to_string(),
1340 period: d("2025-01-01"),
1341 dimension: DiversityDimension::Gender,
1342 level: OrganizationLevel::Corporate,
1343 category: "Female".to_string(),
1344 headcount: 450,
1345 total_headcount: 1000,
1346 percentage: dec!(0.4500),
1347 };
1348
1349 assert_eq!(metric.computed_percentage(), dec!(0.4500));
1350 }
1351
1352 #[test]
1355 fn test_pay_gap_ratio() {
1356 let metric = PayEquityMetric {
1357 id: "PE-001".to_string(),
1358 entity_id: "C001".to_string(),
1359 period: d("2025-01-01"),
1360 dimension: DiversityDimension::Gender,
1361 reference_group: "Male".to_string(),
1362 comparison_group: "Female".to_string(),
1363 reference_median_salary: dec!(85000),
1364 comparison_median_salary: dec!(78000),
1365 pay_gap_ratio: dec!(0.9176),
1366 sample_size: 500,
1367 };
1368
1369 assert_eq!(metric.computed_pay_gap_ratio(), dec!(0.9176));
1371 }
1372
1373 #[test]
1376 fn test_board_independence() {
1377 let metric = GovernanceMetric {
1378 id: "GOV-001".to_string(),
1379 entity_id: "C001".to_string(),
1380 period: d("2025-01-01"),
1381 board_size: 12,
1382 independent_directors: 8,
1383 female_directors: 4,
1384 board_independence_ratio: dec!(0.6667),
1385 board_gender_diversity_ratio: dec!(0.3333),
1386 ethics_training_completion_pct: 0.95,
1387 whistleblower_reports: 3,
1388 anti_corruption_violations: 0,
1389 };
1390
1391 assert_eq!(metric.computed_independence_ratio(), dec!(0.6667));
1392 }
1393
1394 #[test]
1397 fn test_supplier_esg_overall_score() {
1398 let assessment = SupplierEsgAssessment {
1399 id: "SEA-001".to_string(),
1400 entity_id: "C001".to_string(),
1401 vendor_id: "V-001".to_string(),
1402 assessment_date: d("2025-06-15"),
1403 method: AssessmentMethod::ThirdPartyAudit,
1404 environmental_score: dec!(75),
1405 social_score: dec!(80),
1406 governance_score: dec!(85),
1407 overall_score: dec!(80),
1408 risk_flag: EsgRiskFlag::Low,
1409 corrective_actions_required: 0,
1410 };
1411
1412 assert_eq!(assessment.computed_overall_score(), dec!(80.00));
1413 }
1414
1415 #[test]
1418 fn test_materiality_double_threshold() {
1419 let assessment = MaterialityAssessment {
1420 id: "MA-001".to_string(),
1421 entity_id: "C001".to_string(),
1422 period: d("2025-01-01"),
1423 topic: "Climate Change".to_string(),
1424 impact_score: dec!(8.5),
1425 financial_score: dec!(6.0),
1426 combined_score: dec!(7.25),
1427 is_material: true,
1428 };
1429
1430 assert!(assessment.is_material_at_threshold(dec!(7.0)));
1432 assert!(!assessment.is_material_at_threshold(dec!(9.0)));
1434 }
1435
1436 #[test]
1439 fn test_safety_metric_serde_roundtrip() {
1440 let metric = SafetyMetric {
1441 id: "SM-100".to_string(),
1442 entity_id: "C001".to_string(),
1443 period: d("2025-01-01"),
1444 total_hours_worked: 500_000,
1445 recordable_incidents: 3,
1446 lost_time_incidents: 1,
1447 days_away: 10,
1448 near_misses: 8,
1449 fatalities: 0,
1450 trir: dec!(1.2000),
1451 ltir: dec!(0.4000),
1452 dart_rate: dec!(4.0000),
1453 };
1454
1455 let json = serde_json::to_string(&metric).unwrap();
1456 let deserialized: SafetyMetric = serde_json::from_str(&json).unwrap();
1457 assert_eq!(deserialized.trir, dec!(1.2000));
1458 assert_eq!(deserialized.recordable_incidents, 3);
1459 }
1460
1461 #[test]
1462 fn test_climate_scenario_serde() {
1463 let scenario = ClimateScenario {
1464 id: "CS-001".to_string(),
1465 entity_id: "C001".to_string(),
1466 scenario_type: ScenarioType::WellBelow2C,
1467 time_horizon: TimeHorizon::Long,
1468 description: "Paris-aligned scenario".to_string(),
1469 temperature_rise_c: dec!(1.5),
1470 transition_risk_impact: dec!(-50000),
1471 physical_risk_impact: dec!(-10000),
1472 financial_impact: dec!(-60000),
1473 };
1474
1475 let json = serde_json::to_string(&scenario).unwrap();
1476 let deserialized: ClimateScenario = serde_json::from_str(&json).unwrap();
1477 assert_eq!(deserialized.scenario_type, ScenarioType::WellBelow2C);
1478 assert_eq!(deserialized.temperature_rise_c, dec!(1.5));
1479 }
1480}