1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct TechnicalInspection {
12 pub id: Uuid,
13 pub organization_id: Uuid,
14 pub building_id: Uuid,
15
16 pub inspection_type: InspectionType,
18 pub title: String,
19 pub description: Option<String>,
20
21 pub inspector_name: String,
23 pub inspector_company: Option<String>,
24 pub inspector_certification: Option<String>, pub inspection_date: DateTime<Utc>,
28 pub next_due_date: DateTime<Utc>, pub status: InspectionStatus,
32 pub result_summary: Option<String>,
33 pub defects_found: Option<String>,
34 pub recommendations: Option<String>,
35
36 pub compliant: Option<bool>,
38 pub compliance_certificate_number: Option<String>,
39 pub compliance_valid_until: Option<DateTime<Utc>>,
40
41 pub cost: Option<Decimal>,
46 pub invoice_number: Option<String>,
47
48 pub reports: Vec<String>,
50 pub photos: Vec<String>,
51 pub certificates: Vec<String>,
52 pub notes: Option<String>,
53
54 pub created_at: DateTime<Utc>,
56 pub updated_at: DateTime<Utc>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
60#[serde(rename_all = "snake_case")]
61pub enum InspectionType {
62 Elevator, Boiler, Electrical, FireExtinguisher, FireAlarm, GasInstallation, RoofStructure, Facade, WaterQuality, Other { name: String }, }
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
75#[serde(rename_all = "snake_case")]
76pub enum InspectionStatus {
77 Scheduled, InProgress, Completed, Failed, Overdue, Cancelled, }
84
85impl InspectionType {
86 pub fn frequency_days(&self) -> i64 {
88 match self {
89 InspectionType::Elevator => 365, InspectionType::Boiler => 365, InspectionType::Electrical => 365 * 5, InspectionType::FireExtinguisher => 365, InspectionType::FireAlarm => 365, InspectionType::GasInstallation => 365, InspectionType::RoofStructure => 365 * 5, InspectionType::Facade => 365 * 5, InspectionType::WaterQuality => 365, InspectionType::Other { .. } => 365, }
100 }
101
102 pub fn display_name(&self) -> String {
104 match self {
105 InspectionType::Elevator => "Ascenseur".to_string(),
106 InspectionType::Boiler => "Chaudière".to_string(),
107 InspectionType::Electrical => "Installation électrique".to_string(),
108 InspectionType::FireExtinguisher => "Extincteurs".to_string(),
109 InspectionType::FireAlarm => "Alarme incendie".to_string(),
110 InspectionType::GasInstallation => "Installation gaz".to_string(),
111 InspectionType::RoofStructure => "Structure toiture".to_string(),
112 InspectionType::Facade => "Façade".to_string(),
113 InspectionType::WaterQuality => "Qualité de l'eau".to_string(),
114 InspectionType::Other { name } => name.clone(),
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq)]
123pub enum TechnicalInspectionError {
124 NegativeCost,
126}
127
128impl std::fmt::Display for TechnicalInspectionError {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 match self {
131 Self::NegativeCost => write!(f, "Technical inspection cost cannot be negative"),
132 }
133 }
134}
135
136impl std::error::Error for TechnicalInspectionError {}
137
138impl From<TechnicalInspectionError> for String {
140 fn from(e: TechnicalInspectionError) -> String {
141 e.to_string()
142 }
143}
144
145impl TechnicalInspection {
146 pub fn set_cost(&mut self, cost: Option<Decimal>) -> Result<(), TechnicalInspectionError> {
153 if let Some(value) = cost {
154 if value < Decimal::ZERO {
155 return Err(TechnicalInspectionError::NegativeCost);
156 }
157 }
158 self.cost = cost;
159 self.updated_at = Utc::now();
160 Ok(())
161 }
162
163 #[allow(clippy::too_many_arguments)]
164 pub fn new(
165 organization_id: Uuid,
166 building_id: Uuid,
167 title: String,
168 description: Option<String>,
169 inspection_type: InspectionType,
170 inspector_name: String,
171 inspection_date: DateTime<Utc>,
172 ) -> Self {
173 let now = Utc::now();
174
175 let next_due_date =
177 inspection_date + chrono::Duration::days(inspection_type.frequency_days());
178
179 Self {
180 id: Uuid::new_v4(),
181 organization_id,
182 building_id,
183 inspection_type,
184 title,
185 description,
186 inspector_name,
187 inspector_company: None,
188 inspector_certification: None,
189 inspection_date,
190 next_due_date,
191 status: InspectionStatus::Scheduled,
192 result_summary: None,
193 defects_found: None,
194 recommendations: None,
195 compliant: None,
196 compliance_certificate_number: None,
197 compliance_valid_until: None,
198 cost: None,
199 invoice_number: None,
200 reports: Vec::new(),
201 photos: Vec::new(),
202 certificates: Vec::new(),
203 notes: None,
204 created_at: now,
205 updated_at: now,
206 }
207 }
208
209 pub fn calculate_next_due_date(&self) -> DateTime<Utc> {
211 self.inspection_date + chrono::Duration::days(self.inspection_type.frequency_days())
212 }
213
214 pub fn is_overdue(&self) -> bool {
216 Utc::now() > self.next_due_date
217 }
218
219 pub fn days_until_due(&self) -> i64 {
221 (self.next_due_date - Utc::now()).num_days()
222 }
223
224 pub fn mark_overdue(&mut self) {
226 if self.is_overdue() && self.status == InspectionStatus::Scheduled {
227 self.status = InspectionStatus::Overdue;
228 self.updated_at = Utc::now();
229 }
230 }
231
232 pub fn add_report(&mut self, report_path: String) {
234 self.reports.push(report_path);
235 self.updated_at = Utc::now();
236 }
237
238 pub fn add_photo(&mut self, photo_path: String) {
240 self.photos.push(photo_path);
241 self.updated_at = Utc::now();
242 }
243
244 pub fn add_certificate(&mut self, certificate_path: String) {
246 self.certificates.push(certificate_path);
247 self.updated_at = Utc::now();
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use rust_decimal_macros::dec;
255
256 #[test]
257 fn test_inspection_creation() {
258 let inspection = TechnicalInspection::new(
259 Uuid::new_v4(),
260 Uuid::new_v4(),
261 "Inspection annuelle ascenseur".to_string(),
262 Some("Vérification complète".to_string()),
263 InspectionType::Elevator,
264 "Schindler Belgium".to_string(),
265 Utc::now(),
266 );
267
268 assert_eq!(inspection.title, "Inspection annuelle ascenseur");
269 assert_eq!(inspection.status, InspectionStatus::Scheduled);
270 assert!(!inspection.is_overdue());
271 }
272
273 #[test]
274 fn test_inspection_frequencies() {
275 assert_eq!(InspectionType::Elevator.frequency_days(), 365);
276 assert_eq!(InspectionType::Electrical.frequency_days(), 365 * 5);
277 assert_eq!(InspectionType::Facade.frequency_days(), 365 * 5);
278 }
279
280 #[test]
281 fn test_inspection_completion() {
282 let mut inspection = TechnicalInspection::new(
283 Uuid::new_v4(),
284 Uuid::new_v4(),
285 "Inspection chaudière".to_string(),
286 None,
287 InspectionType::Boiler,
288 "Test Inspector".to_string(),
289 Utc::now(),
290 );
291
292 inspection.status = InspectionStatus::Completed;
293 inspection.compliant = Some(true);
294 assert_eq!(inspection.status, InspectionStatus::Completed);
295 assert_eq!(inspection.compliant, Some(true));
296 }
297
298 #[test]
299 fn test_overdue_detection() {
300 let past_date = Utc::now() - chrono::Duration::days(400); let mut inspection = TechnicalInspection::new(
302 Uuid::new_v4(),
303 Uuid::new_v4(),
304 "Test".to_string(),
305 None,
306 InspectionType::FireExtinguisher,
307 "Test".to_string(),
308 past_date,
309 );
310
311 assert!(inspection.is_overdue());
312 assert!(inspection.days_until_due() < 0);
313
314 inspection.mark_overdue();
315 assert_eq!(inspection.status, InspectionStatus::Overdue);
316 }
317
318 fn make_inspection() -> TechnicalInspection {
321 TechnicalInspection::new(
322 Uuid::new_v4(),
323 Uuid::new_v4(),
324 "Inspection annuelle ascenseur".to_string(),
325 None,
326 InspectionType::Elevator,
327 "Schindler Belgium".to_string(),
328 Utc::now(),
329 )
330 }
331
332 #[test]
334 fn happy_set_cost_records_the_amount() {
335 let mut inspection = make_inspection();
336 let before = inspection.updated_at;
337
338 inspection
339 .set_cost(Some(dec!(450.00)))
340 .expect("coût valide");
341
342 assert_eq!(inspection.cost, Some(dec!(450.00)));
343 assert!(inspection.updated_at >= before);
344 }
345
346 #[test]
348 fn happy_set_cost_none_is_accepted() {
349 let mut inspection = make_inspection();
350 inspection.set_cost(Some(dec!(10.00))).expect("coût valide");
351
352 inspection.set_cost(None).expect("absence de coût valide");
353
354 assert_eq!(inspection.cost, None);
355 }
356
357 #[test]
360 fn edge_zero_accepted_minus_one_cent_rejected() {
361 let mut inspection = make_inspection();
362
363 inspection
364 .set_cost(Some(Decimal::ZERO))
365 .expect("zéro est un coût valide");
366 assert_eq!(inspection.cost, Some(Decimal::ZERO));
367
368 assert_eq!(
369 inspection.set_cost(Some(dec!(-0.01))).unwrap_err(),
370 TechnicalInspectionError::NegativeCost
371 );
372 }
373
374 #[test]
377 fn edge_decimal_arithmetic_is_exact() {
378 let mut inspection = make_inspection();
379 inspection.set_cost(Some(dec!(0.10))).expect("coût valide");
380
381 let cumulated = inspection.cost.expect("coût posé") + dec!(0.20);
382 inspection.set_cost(Some(cumulated)).expect("coût valide");
383
384 assert_eq!(inspection.cost, Some(dec!(0.30)));
385 }
386
387 #[test]
389 fn negative_rejected_set_cost_leaves_the_entity_untouched() {
390 let mut inspection = make_inspection();
391 inspection
392 .set_cost(Some(dec!(120.00)))
393 .expect("coût valide");
394 let before = inspection.updated_at;
395
396 let _ = inspection.set_cost(Some(dec!(-5.00)));
397
398 assert_eq!(inspection.cost, Some(dec!(120.00)));
399 assert_eq!(inspection.updated_at, before);
400 }
401
402 #[test]
407 fn security_negative_cost_cannot_bypass_the_domain() {
408 let mut inspection = make_inspection();
409
410 assert!(inspection.set_cost(Some(dec!(-999999.99))).is_err());
411 assert_eq!(inspection.cost, None);
412 }
413}