1use bon::Builder;
11use serde::{Deserialize, Serialize};
12
13use crate::ocpi_lenient_enum;
14use crate::types::validate_fields;
15use crate::types::{
16 CiString, Currency, DateTime, Extensions, Number, OcpiString, Validate, Validator, ViolationCode,
17};
18
19use super::locations::Location;
20use super::tariffs::Tariff;
21
22#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[builder(on(_, into))]
28pub struct Cdr {
29 pub id: CiString<36>,
31 pub start_date_time: DateTime,
33 pub stop_date_time: DateTime,
37 pub auth_id: OcpiString<36>,
41 pub auth_method: AuthMethod,
43 pub location: Location,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub meter_id: Option<OcpiString<255>>,
52 pub currency: Currency,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 #[builder(default)]
57 pub tariffs: Vec<Tariff>,
58 pub charging_periods: Vec<ChargingPeriod>,
60 pub total_cost: Number,
62 pub total_energy: Number,
64 pub total_time: Number,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub total_parking_time: Option<Number>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub remark: Option<OcpiString<255>>,
72 pub last_updated: DateTime,
74 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
76 #[builder(default)]
77 pub extensions: Extensions,
78}
79
80impl Cdr {
81 #[must_use]
83 pub fn total_charging_time(&self) -> Number {
84 self.total_time - self.total_parking_time.unwrap_or(Number::ZERO)
85 }
86
87 #[must_use]
89 pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
90 self.charging_periods
91 .iter()
92 .flat_map(|p| p.dimensions.iter())
93 .filter(|d| d.dimension_type == dimension)
94 .map(|d| d.volume)
95 .sum()
96 }
97}
98
99impl Validate for Cdr {
100 fn validate_in(&self, v: &mut Validator) {
101 validate_fields!(
102 self,
103 v,
104 id,
105 start_date_time,
106 stop_date_time,
107 auth_id,
108 auth_method,
109 location,
110 meter_id,
111 currency,
112 tariffs,
113 charging_periods,
114 total_cost,
115 total_energy,
116 total_time,
117 total_parking_time,
118 remark,
119 last_updated,
120 );
121 crate::v2_3_0::cdrs::validate_period_sequence(
122 &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
123 self.start_date_time,
124 Some(self.stop_date_time),
125 v,
126 );
127 if self.charging_periods.is_empty() {
128 v.report_at(
129 "charging_periods",
130 ViolationCode::EmptyRequiredList,
131 "a CDR has cardinality `+` charging_periods: at least one is required",
132 );
133 }
134 if self.stop_date_time < self.start_date_time {
135 v.report_at(
136 "stop_date_time",
137 ViolationCode::Inconsistent,
138 "a session cannot stop before it starts",
139 );
140 }
141 if self.total_parking_time.is_some_and(|p| p > self.total_time) {
142 v.report_at(
143 "total_parking_time",
144 ViolationCode::Inconsistent,
145 "cannot exceed total_time, of which it is a part",
146 );
147 }
148 }
149}
150
151#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159#[builder(on(_, into))]
160pub struct ChargingPeriod {
161 pub start_date_time: DateTime,
163 pub dimensions: Vec<CdrDimension>,
165 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
167 #[builder(default)]
168 pub extensions: Extensions,
169}
170
171impl ChargingPeriod {
172 #[must_use]
174 pub fn volume(&self, dimension: CdrDimensionType) -> Option<Number> {
175 self.dimensions.iter().find(|d| d.dimension_type == dimension).map(|d| d.volume)
176 }
177}
178
179impl Validate for ChargingPeriod {
180 fn validate_in(&self, v: &mut Validator) {
181 validate_fields!(self, v, start_date_time, dimensions);
182 if self.dimensions.is_empty() {
183 v.report_at(
184 "dimensions",
185 ViolationCode::EmptyRequiredList,
186 "a ChargingPeriod has cardinality `+` dimensions: at least one is required",
187 );
188 }
189 }
190}
191
192#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct CdrDimension {
198 #[serde(rename = "type")]
200 pub dimension_type: CdrDimensionType,
201 pub volume: Number,
203 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
205 pub extensions: Extensions,
206}
207
208impl CdrDimension {
209 #[must_use]
211 pub fn new(dimension_type: CdrDimensionType, volume: Number) -> Self {
212 Self { dimension_type, volume, extensions: Extensions::new() }
213 }
214}
215
216impl Validate for CdrDimension {
217 fn validate_in(&self, v: &mut Validator) {
218 validate_fields!(self, v, dimension_type as "type", volume);
219 }
220}
221
222ocpi_lenient_enum! {
223 pub enum AuthMethod {
229 AuthRequest = "AUTH_REQUEST",
231 Whitelist = "WHITELIST",
233 }
234}
235
236ocpi_lenient_enum! {
237 pub enum CdrDimensionType {
243 Energy = "ENERGY",
245 Flat = "FLAT",
247 MaxCurrent = "MAX_CURRENT",
249 MinCurrent = "MIN_CURRENT",
251 ParkingTime = "PARKING_TIME",
253 Time = "TIME",
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn the_2_1_1_dimension_set_is_the_small_one() {
264 assert_eq!(CdrDimensionType::ALL_KNOWN.len(), 6);
265 assert!(!CdrDimensionType::from("STATE_OF_CHARGE").is_known());
267 assert!(!AuthMethod::from("COMMAND").is_known(), "COMMAND arrived with the Commands module");
268 }
269
270 #[test]
271 fn the_cost_is_a_bare_number_excluding_vat() {
272 let json = r#"{"volume":12.5,"type":"ENERGY"}"#;
273 let dimension: CdrDimension = serde_json::from_str(json).unwrap();
274 assert_eq!(dimension.dimension_type, CdrDimensionType::Energy);
275 assert_eq!(serde_json::to_string(&dimension).unwrap(), r#"{"type":"ENERGY","volume":12.5}"#);
276 }
277}