1use bon::Builder;
12use serde::{Deserialize, Serialize};
13
14use crate::ocpi_enum;
15use crate::types::validate_fields;
16use crate::types::{
17 CiString, ContractId, CountryCode, Currency, DateTime, EvseId, Extensions, Number, OcpiString, PartyId,
18 PartyRef, Url, Validate, Validator, ViolationCode,
19};
20
21use super::locations::{ConnectorFormat, ConnectorType, GeoLocation, PowerType};
22use super::tariffs::Tariff;
23use super::tokens::TokenType;
24use super::types::Price;
25
26pub const NON_CREDIT_ID_MAX_LEN: usize = 36;
32
33#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[builder(on(_, into))]
39pub struct Cdr {
40 pub country_code: CountryCode,
42 pub party_id: PartyId,
44 pub id: CiString<39>,
46 pub start_date_time: DateTime,
48 pub end_date_time: DateTime,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub session_id: Option<CiString<36>>,
56 pub cdr_token: CdrToken,
58 pub auth_method: AuthMethod,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub authorization_reference: Option<CiString<36>>,
63 #[cfg(feature = "bookings")]
71 #[cfg_attr(docsrs, doc(cfg(feature = "bookings")))]
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub booking_id: Option<CiString<36>>,
74 pub cdr_location: CdrLocation,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub meter_id: Option<OcpiString<255>>,
79 pub currency: Currency,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 #[builder(default)]
84 pub tariffs: Vec<Tariff>,
85 pub charging_periods: Vec<ChargingPeriod>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub signed_data: Option<SignedData>,
90 pub total_cost: Price,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub total_fixed_cost: Option<Price>,
95 pub total_energy: Number,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub total_energy_cost: Option<Price>,
100 pub total_time: Number,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub total_time_cost: Option<Price>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub total_parking_time: Option<Number>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub total_parking_cost: Option<Price>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub total_reservation_cost: Option<Price>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub remark: Option<OcpiString<255>>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub invoice_reference_id: Option<CiString<39>>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub credit: Option<bool>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub credit_reference_id: Option<CiString<39>>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub home_charging_compensation: Option<bool>,
129 pub last_updated: DateTime,
131 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
133 #[builder(default)]
134 pub extensions: Extensions,
135}
136
137impl Cdr {
138 #[must_use]
140 pub fn owner_party(&self) -> PartyRef {
141 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
142 }
143
144 #[must_use]
146 pub fn is_credit(&self) -> bool {
147 self.credit.unwrap_or(false)
148 }
149
150 #[must_use]
155 pub fn total_charging_time(&self) -> Number {
156 self.total_time - self.total_parking_time.unwrap_or(Number::ZERO)
157 }
158
159 #[must_use]
161 pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
162 self.charging_periods
163 .iter()
164 .flat_map(|p| p.dimensions.iter())
165 .filter(|d| d.dimension_type == dimension)
166 .map(|d| d.volume)
167 .sum()
168 }
169
170 #[must_use]
176 pub fn has_placeholder_timestamps(&self) -> bool {
177 self.start_date_time.unix_timestamp() == 0 || self.end_date_time.unix_timestamp() == 0
178 }
179}
180
181impl Validate for Cdr {
182 fn validate_in(&self, v: &mut Validator) {
183 validate_fields!(
184 self,
185 v,
186 country_code,
187 party_id,
188 id,
189 start_date_time,
190 end_date_time,
191 session_id,
192 cdr_token,
193 auth_method,
194 authorization_reference,
195 cdr_location,
196 meter_id,
197 currency,
198 tariffs,
199 charging_periods,
200 signed_data,
201 total_cost,
202 total_fixed_cost,
203 total_energy,
204 total_energy_cost,
205 total_time,
206 total_time_cost,
207 total_parking_time,
208 total_parking_cost,
209 total_reservation_cost,
210 remark,
211 invoice_reference_id,
212 credit_reference_id,
213 last_updated,
214 );
215
216 if self.charging_periods.is_empty() {
217 v.report_at(
218 "charging_periods",
219 ViolationCode::EmptyRequiredList,
220 "a CDR has cardinality `+` charging_periods: at least one is required",
221 );
222 }
223
224 if !self.is_credit() && self.id.len() > NON_CREDIT_ID_MAX_LEN {
226 v.report_at(
227 "id",
228 ViolationCode::TooLong,
229 format!(
230 "a non-credit CDR id may be at most {NON_CREDIT_ID_MAX_LEN} characters; \
231 the extra length is reserved for credit CDRs"
232 ),
233 );
234 }
235
236 if self.is_credit() && self.credit_reference_id.is_none() {
239 v.report_at(
240 "credit_reference_id",
241 ViolationCode::MissingConditional,
242 "is required to be set for a Credit CDR",
243 );
244 }
245 if !self.is_credit() && self.credit_reference_id.is_some() {
246 v.report_at(
247 "credit",
248 ViolationCode::Inconsistent,
249 "credit_reference_id is set, so `credit` should be true",
250 );
251 }
252
253 if !self.has_placeholder_timestamps() && self.end_date_time < self.start_date_time {
254 v.report_at(
255 "end_date_time",
256 ViolationCode::Inconsistent,
257 "a session cannot end before it starts",
258 );
259 }
260
261 let metered = self.dimension_total(CdrDimensionType::Energy);
263 if !self.charging_periods.is_empty()
264 && self
265 .charging_periods
266 .iter()
267 .any(|p| p.dimensions.iter().any(|d| d.dimension_type == CdrDimensionType::Energy))
268 && metered != self.total_energy
269 {
270 v.report_at(
271 "total_energy",
272 ViolationCode::Inconsistent,
273 format!(
274 "is {}, but the ENERGY dimensions of the charging periods add up to {metered}",
275 self.total_energy
276 ),
277 );
278 }
279
280 validate_period_sequence(
281 &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
282 self.start_date_time,
283 Some(self.end_date_time),
284 v,
285 );
286
287 if self.total_parking_time.is_some_and(|p| p > self.total_time) {
288 v.report_at(
289 "total_parking_time",
290 ViolationCode::Inconsistent,
291 "cannot exceed total_time, of which it is a part",
292 );
293 }
294
295 for (i, period) in self.charging_periods.iter().enumerate() {
297 for (j, dim) in period.dimensions.iter().enumerate() {
298 if dim.dimension_type.is_session_only() {
299 v.enter("charging_periods");
300 v.enter(&i.to_string());
301 v.enter("dimensions");
302 v.enter(&j.to_string());
303 v.report_at(
304 "type",
305 ViolationCode::Inconsistent,
306 format!(
307 "{} is marked \"Session Only\" and SHALL NOT appear in a CDR",
308 dim.dimension_type
309 ),
310 );
311 v.leave();
312 v.leave();
313 v.leave();
314 v.leave();
315 }
316 }
317 }
318 }
319}
320
321#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326#[builder(on(_, into))]
327pub struct CdrToken {
328 pub country_code: CountryCode,
330 pub party_id: PartyId,
332 pub uid: CiString<36>,
334 #[serde(rename = "type")]
336 pub token_type: TokenType,
337 pub contract_id: ContractId,
339 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
341 #[builder(default)]
342 pub extensions: Extensions,
343}
344
345impl CdrToken {
346 #[must_use]
348 pub fn owner_party(&self) -> PartyRef {
349 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
350 }
351}
352
353impl Validate for CdrToken {
354 fn validate_in(&self, v: &mut Validator) {
355 validate_fields!(self, v, country_code, party_id, uid, token_type as "type", contract_id);
356 }
357}
358
359#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
363#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
364#[builder(on(_, into))]
365pub struct CdrLocation {
366 pub id: CiString<36>,
368 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub name: Option<OcpiString<255>>,
371 pub address: OcpiString<45>,
373 pub city: OcpiString<45>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub postal_code: Option<OcpiString<10>>,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub state: Option<OcpiString<20>>,
381 pub country: OcpiString<3>,
383 pub coordinates: GeoLocation,
385 pub evse_uid: CiString<36>,
387 pub evse_id: EvseId,
389 pub connector_id: CiString<36>,
391 pub connector_standard: ConnectorType,
393 pub connector_format: ConnectorFormat,
395 pub connector_power_type: PowerType,
397 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
399 #[builder(default)]
400 pub extensions: Extensions,
401}
402
403impl CdrLocation {
404 #[must_use]
410 pub fn is_reservation_only(&self) -> bool {
411 self.evse_uid.is_not_available()
412 || self.evse_id.is_not_available()
413 || self.connector_id.is_not_available()
414 }
415}
416
417impl Validate for CdrLocation {
418 fn validate_in(&self, v: &mut Validator) {
419 validate_fields!(
420 self,
421 v,
422 id,
423 name,
424 address,
425 city,
426 postal_code,
427 state,
428 country,
429 coordinates,
430 evse_uid,
431 evse_id,
432 connector_id,
433 connector_standard,
434 connector_format,
435 connector_power_type,
436 );
437 }
438}
439
440#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
447#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
448#[builder(on(_, into))]
449pub struct ChargingPeriod {
450 pub start_date_time: DateTime,
452 pub dimensions: Vec<CdrDimension>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 pub tariff_id: Option<CiString<36>>,
457 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
459 #[builder(default)]
460 pub extensions: Extensions,
461}
462
463impl ChargingPeriod {
464 #[must_use]
466 pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
467 self.dimensions.iter().find(|d| d.dimension_type == dimension).map(|d| d.volume)
468 }
469}
470
471impl Validate for ChargingPeriod {
472 fn validate_in(&self, v: &mut Validator) {
473 validate_fields!(self, v, start_date_time, dimensions, tariff_id);
474 if self.dimensions.is_empty() {
475 v.report_at(
476 "dimensions",
477 ViolationCode::EmptyRequiredList,
478 "a ChargingPeriod has cardinality `+` dimensions: at least one is required",
479 );
480 }
481 let mut seen: Vec<&CdrDimensionType> = Vec::new();
482 for d in &self.dimensions {
483 if seen.contains(&&d.dimension_type) {
484 v.report_at(
485 "dimensions",
486 ViolationCode::Inconsistent,
487 format!("the dimension {} appears more than once in one period", d.dimension_type),
488 );
489 }
490 seen.push(&d.dimension_type);
491 }
492 }
493}
494
495pub fn validate_period_sequence(
514 starts: &[DateTime],
515 session_start: DateTime,
516 session_end: Option<DateTime>,
517 v: &mut Validator,
518) {
519 let mut previous: Option<DateTime> = None;
520 for (i, start) in starts.iter().copied().enumerate() {
521 let at = |v: &mut Validator, message: String| {
522 v.enter("charging_periods");
523 v.enter(&i.to_string());
524 v.report_at("start_date_time", ViolationCode::Inconsistent, message);
525 v.leave();
526 v.leave();
527 };
528 if let Some(previous) = previous
529 && start <= previous
530 {
531 at(
532 v,
533 format!(
534 "is {start}, which is not after the previous period's {previous}; \
535 charging periods have to be in order for `step_size` and for a period's \
536 own duration to mean anything"
537 ),
538 );
539 }
540 if start < session_start {
541 at(v, format!("is {start}, before the session started at {session_start}"));
542 }
543 if let Some(end) = session_end
544 && start >= end
545 {
546 at(v, format!("is {start}, at or after the session ended at {end}"));
547 }
548 previous = Some(start);
549 }
550}
551
552#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
556#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
557pub struct CdrDimension {
558 #[serde(rename = "type")]
560 pub dimension_type: CdrDimensionType,
561 pub volume: Number,
563 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
565 pub extensions: Extensions,
566}
567
568impl CdrDimension {
569 #[must_use]
571 pub fn new(dimension_type: CdrDimensionType, volume: Number) -> Self {
572 Self { dimension_type, volume, extensions: Extensions::new() }
573 }
574}
575
576impl Validate for CdrDimension {
577 fn validate_in(&self, v: &mut Validator) {
578 validate_fields!(self, v, dimension_type as "type", volume);
579 if self.dimension_type == CdrDimensionType::StateOfCharge {
580 let pct = self.volume;
581 if pct < Number::ZERO || pct > Number::from(100u32) {
582 v.report_at(
583 "volume",
584 ViolationCode::OutOfRange,
585 "STATE_OF_CHARGE is a percentage: values allowed are 0 to 100",
586 );
587 }
588 }
589 if !self.dimension_type.may_be_negative() && self.volume.is_negative() {
590 v.report_at(
591 "volume",
592 ViolationCode::OutOfRange,
593 format!("{} cannot be negative", self.dimension_type),
594 );
595 }
596 }
597}
598
599#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
603#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
604#[builder(on(_, into))]
605pub struct SignedData {
606 pub encoding_method: CiString<36>,
611 #[serde(default, skip_serializing_if = "Option::is_none")]
613 pub encoding_method_version: Option<i32>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub public_key: Option<OcpiString<512>>,
617 pub signed_values: Vec<SignedValue>,
619 #[serde(default, skip_serializing_if = "Option::is_none")]
621 pub url: Option<Url>,
622 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
624 #[builder(default)]
625 pub extensions: Extensions,
626}
627
628impl Validate for SignedData {
629 fn validate_in(&self, v: &mut Validator) {
630 validate_fields!(self, v, encoding_method, public_key, signed_values, url,);
631 if self.signed_values.is_empty() {
632 v.report_at(
633 "signed_values",
634 ViolationCode::EmptyRequiredList,
635 "SignedData has cardinality `+` signed_values: at least one is required",
636 );
637 }
638 }
639}
640
641#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
645#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
646pub struct SignedValue {
647 pub nature: CiString<32>,
652 pub plain_data: OcpiString<5000>,
656 pub signed_data: OcpiString<5000>,
658 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
660 pub extensions: Extensions,
661}
662
663impl Validate for SignedValue {
664 fn validate_in(&self, v: &mut Validator) {
665 validate_fields!(self, v, nature, plain_data, signed_data);
666 }
667}
668
669ocpi_enum! {
670 pub enum AuthMethod {
674 AuthRequest = "AUTH_REQUEST",
676 Command = "COMMAND",
678 Whitelist = "WHITELIST",
680 }
681}
682
683ocpi_enum! {
684 pub enum CdrDimensionType {
691 Current = "CURRENT",
693 Energy = "ENERGY",
695 EnergyExport = "ENERGY_EXPORT",
697 EnergyImport = "ENERGY_IMPORT",
699 MaxCurrent = "MAX_CURRENT",
701 MinCurrent = "MIN_CURRENT",
703 MaxPower = "MAX_POWER",
705 MinPower = "MIN_POWER",
707 ParkingTime = "PARKING_TIME",
712 Power = "POWER",
714 ReservationTime = "RESERVATION_TIME",
716 ReservationExpires = "RESERVATION_EXPIRES",
724 ReservationOvertime = "RESERVATION_OVERTIME",
730 StateOfCharge = "STATE_OF_CHARGE",
732 Time = "TIME",
734 }
735}
736
737impl CdrDimensionType {
738 #[must_use]
745 pub const fn is_session_only(self) -> bool {
746 matches!(
747 self,
748 Self::Current | Self::EnergyExport | Self::EnergyImport | Self::Power | Self::StateOfCharge
749 )
750 }
751
752 #[must_use]
757 pub const fn may_be_negative(self) -> bool {
758 matches!(self, Self::Current | Self::Energy | Self::MinCurrent | Self::MinPower | Self::Power)
759 }
760
761 #[must_use]
763 pub const fn unit(self) -> &'static str {
764 match self {
765 Self::Current | Self::MaxCurrent | Self::MinCurrent => "A",
766 Self::Energy | Self::EnergyExport | Self::EnergyImport => "kWh",
767 Self::MaxPower | Self::MinPower | Self::Power => "kW",
768 Self::ParkingTime
769 | Self::ReservationTime
770 | Self::ReservationExpires
771 | Self::ReservationOvertime
772 | Self::Time => "h",
773 Self::StateOfCharge => "%",
774 }
775 }
776}
777
778#[cfg(test)]
779mod dimension_tests {
780 use super::*;
781
782 #[test]
786 fn the_bookings_branch_reservation_dimensions_decode() {
787 for (wire, expected, unit) in [
788 ("RESERVATION_TIME", CdrDimensionType::ReservationTime, "h"),
789 ("RESERVATION_EXPIRES", CdrDimensionType::ReservationExpires, "h"),
790 ("RESERVATION_OVERTIME", CdrDimensionType::ReservationOvertime, "h"),
791 ] {
792 let decoded: CdrDimensionType =
793 serde_json::from_str(&format!("\"{wire}\"")).unwrap_or_else(|e| panic!("{wire}: {e}"));
794 assert_eq!(decoded, expected);
795 assert_eq!(serde_json::to_string(&decoded).expect("serialises"), format!("\"{wire}\""));
796 assert_eq!(decoded.unit(), unit);
797 assert!(!decoded.is_session_only(), "{wire} has no Session-Only mark in the branch table");
798 }
799 }
800}
801
802#[cfg(test)]
803mod period_sequence_tests {
804 use super::*;
805 use crate::types::Violation;
806
807 fn dt(s: &str) -> DateTime {
808 s.parse().expect("a valid timestamp")
809 }
810
811 fn check(starts: &[&str], start: &str, end: Option<&str>) -> Vec<Violation> {
812 let mut v = Validator::new();
813 validate_period_sequence(
814 &starts.iter().map(|s| dt(s)).collect::<Vec<_>>(),
815 dt(start),
816 end.map(dt),
817 &mut v,
818 );
819 v.finish().into_vec()
820 }
821
822 #[test]
823 fn a_well_formed_sequence_is_accepted() {
824 assert!(
825 check(
826 &["2024-01-15T10:00:00Z", "2024-01-15T10:30:00Z", "2024-01-15T11:00:00Z"],
827 "2024-01-15T10:00:00Z",
828 Some("2024-01-15T11:30:00Z"),
829 )
830 .is_empty()
831 );
832 }
833
834 #[test]
835 fn periods_out_of_order_are_reported_at_the_offending_index() {
836 let found = check(
838 &["2024-01-15T10:00:00Z", "2024-01-15T11:00:00Z", "2024-01-15T10:30:00Z"],
839 "2024-01-15T10:00:00Z",
840 Some("2024-01-15T12:00:00Z"),
841 );
842 assert_eq!(found.len(), 1, "{found:?}");
843 assert_eq!(found[0].pointer, "/charging_periods/2/start_date_time");
844 assert_eq!(found[0].code, ViolationCode::Inconsistent);
845 }
846
847 #[test]
848 fn two_periods_at_the_same_instant_are_reported() {
849 let found = check(&["2024-01-15T10:00:00Z", "2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None);
851 assert_eq!(found.len(), 1, "{found:?}");
852 assert_eq!(found[0].pointer, "/charging_periods/1/start_date_time");
853 }
854
855 #[test]
856 fn a_period_outside_the_session_is_reported() {
857 let before = check(&["2024-01-15T09:00:00Z"], "2024-01-15T10:00:00Z", None);
858 assert_eq!(before.len(), 1);
859 assert!(before[0].message.contains("before the session started"), "{:?}", before[0]);
860
861 let after = check(&["2024-01-15T13:00:00Z"], "2024-01-15T10:00:00Z", Some("2024-01-15T12:00:00Z"));
862 assert_eq!(after.len(), 1);
863 assert!(after[0].message.contains("after the session ended"), "{:?}", after[0]);
864 }
865
866 #[test]
867 fn an_empty_or_single_period_list_has_nothing_to_disagree_with() {
868 assert!(check(&[], "2024-01-15T10:00:00Z", None).is_empty());
869 assert!(check(&["2024-01-15T10:00:00Z"], "2024-01-15T10:00:00Z", None).is_empty());
870 }
871}
872
873#[cfg(test)]
874mod tests {
875 use super::*;
876
877 fn dim(t: CdrDimensionType, v: &str) -> CdrDimension {
878 CdrDimension::new(t, v.parse().unwrap())
879 }
880
881 #[test]
882 fn session_only_dimensions_are_rejected_in_a_cdr() {
883 let p = ChargingPeriod::builder()
884 .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
885 .dimensions(vec![dim(CdrDimensionType::StateOfCharge, "50")])
886 .build();
887 assert!(p.validate().is_ok(), "a Session may carry STATE_OF_CHARGE");
888 assert!(CdrDimensionType::StateOfCharge.is_session_only());
889 assert!(!CdrDimensionType::Energy.is_session_only());
890 }
891
892 #[test]
893 fn dimension_units_and_signs_follow_the_table() {
894 assert_eq!(CdrDimensionType::Energy.unit(), "kWh");
895 assert_eq!(CdrDimensionType::ParkingTime.unit(), "h");
896 assert!(CdrDimensionType::Power.may_be_negative(), "V2G power flows both ways");
897 assert!(!CdrDimensionType::ParkingTime.may_be_negative());
898 assert!(dim(CdrDimensionType::ParkingTime, "-1").validate().is_err());
899 assert!(dim(CdrDimensionType::Power, "-7.5").validate().is_ok());
900 assert!(dim(CdrDimensionType::StateOfCharge, "101").validate().is_err());
901 }
902
903 #[test]
904 fn a_period_cannot_measure_the_same_dimension_twice() {
905 let p = ChargingPeriod::builder()
906 .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
907 .dimensions(vec![dim(CdrDimensionType::Energy, "1"), dim(CdrDimensionType::Energy, "2")])
908 .build();
909 assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::Inconsistent);
910 }
911
912 #[test]
913 fn empty_dimensions_are_a_cardinality_violation() {
914 let p = ChargingPeriod::builder()
915 .start_date_time("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
916 .dimensions(vec![])
917 .build();
918 assert_eq!(p.validate().unwrap_err().as_slice()[0].code, ViolationCode::EmptyRequiredList);
919 }
920}