koprogo_api/domain/economie_circulaire/
work_report.rs1use chrono::{DateTime, Utc};
2use rust_decimal::Decimal;
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub struct WorkReport {
12 pub id: Uuid,
13 pub organization_id: Uuid,
14 pub building_id: Uuid,
15
16 pub title: String,
18 pub description: String,
19 pub work_type: WorkType,
20 pub contractor_name: String,
21 pub contractor_contact: Option<String>,
22
23 pub work_date: DateTime<Utc>, pub completion_date: Option<DateTime<Utc>>, pub cost: Decimal,
32 pub invoice_number: Option<String>,
33
34 pub photos: Vec<String>, pub documents: Vec<String>, pub notes: Option<String>,
38
39 pub warranty_type: WarrantyType,
41 pub warranty_expiry: DateTime<Utc>,
42
43 pub created_at: DateTime<Utc>,
45 pub updated_at: DateTime<Utc>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
49#[serde(rename_all = "snake_case")]
50pub enum WorkType {
51 Maintenance, Repair, Renovation, Emergency, Inspection, Installation, Other,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
61#[serde(rename_all = "snake_case")]
62pub enum WarrantyType {
63 None, Standard, Decennial, Extended, Custom { years: i32 }, }
69
70#[derive(Debug, Clone, PartialEq)]
76pub enum WorkReportError {
77 NegativeCost,
79}
80
81impl std::fmt::Display for WorkReportError {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 match self {
84 Self::NegativeCost => write!(f, "Work report cost cannot be negative"),
85 }
86 }
87}
88
89impl std::error::Error for WorkReportError {}
90
91impl From<WorkReportError> for String {
94 fn from(e: WorkReportError) -> String {
95 e.to_string()
96 }
97}
98
99impl WorkReport {
100 #[allow(clippy::too_many_arguments)]
101 pub fn new(
102 organization_id: Uuid,
103 building_id: Uuid,
104 title: String,
105 description: String,
106 work_type: WorkType,
107 contractor_name: String,
108 work_date: DateTime<Utc>,
109 cost: Decimal,
110 warranty_type: WarrantyType,
111 ) -> Result<Self, WorkReportError> {
112 if cost < Decimal::ZERO {
118 return Err(WorkReportError::NegativeCost);
119 }
120
121 let now = Utc::now();
122
123 let warranty_expiry = match warranty_type {
125 WarrantyType::None => now, WarrantyType::Standard => work_date + chrono::Duration::days(2 * 365), WarrantyType::Decennial => work_date + chrono::Duration::days(10 * 365), WarrantyType::Extended => work_date + chrono::Duration::days(3 * 365), WarrantyType::Custom { years } => {
130 work_date + chrono::Duration::days(years as i64 * 365)
131 }
132 };
133
134 Ok(Self {
135 id: Uuid::new_v4(),
136 organization_id,
137 building_id,
138 title,
139 description,
140 work_type,
141 contractor_name,
142 contractor_contact: None,
143 work_date,
144 completion_date: None,
145 cost,
146 invoice_number: None,
147 photos: Vec::new(),
148 documents: Vec::new(),
149 notes: None,
150 warranty_type,
151 warranty_expiry,
152 created_at: now,
153 updated_at: now,
154 })
155 }
156
157 pub fn set_cost(&mut self, cost: Decimal) -> Result<(), WorkReportError> {
163 if cost < Decimal::ZERO {
164 return Err(WorkReportError::NegativeCost);
165 }
166 self.cost = cost;
167 self.updated_at = Utc::now();
168 Ok(())
169 }
170
171 pub fn is_warranty_valid(&self) -> bool {
173 Utc::now() < self.warranty_expiry
174 }
175
176 pub fn warranty_days_remaining(&self) -> i64 {
178 let now = Utc::now();
179 if now >= self.warranty_expiry {
180 0
181 } else {
182 (self.warranty_expiry - now).num_days()
183 }
184 }
185
186 pub fn add_photo(&mut self, photo_path: String) {
188 self.photos.push(photo_path);
189 self.updated_at = Utc::now();
190 }
191
192 pub fn add_document(&mut self, document_path: String) {
194 self.documents.push(document_path);
195 self.updated_at = Utc::now();
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use rust_decimal_macros::dec;
203
204 fn make(cost: Decimal, warranty: WarrantyType) -> Result<WorkReport, WorkReportError> {
205 WorkReport::new(
206 Uuid::new_v4(),
207 Uuid::new_v4(),
208 "Réparation ascenseur".to_string(),
209 "Remplacement câble principal".to_string(),
210 WorkType::Repair,
211 "Schindler Belgium".to_string(),
212 Utc::now(),
213 cost,
214 warranty,
215 )
216 }
217
218 #[test]
221 fn happy_work_report_creation() {
222 let report = make(dec!(1500.00), WarrantyType::Standard).expect("coût valide");
223
224 assert_eq!(report.title, "Réparation ascenseur");
225 assert_eq!(report.cost, dec!(1500.00));
226 assert!(report.is_warranty_valid());
227 assert!(report.warranty_days_remaining() > 700); }
229
230 #[test]
231 fn happy_decennial_warranty() {
232 let report = WorkReport::new(
233 Uuid::new_v4(),
234 Uuid::new_v4(),
235 "Rénovation façade".to_string(),
236 "Réfection complète façade".to_string(),
237 WorkType::Renovation,
238 "BatiPro SPRL".to_string(),
239 Utc::now(),
240 dec!(50000.00),
241 WarrantyType::Decennial,
242 )
243 .expect("coût valide");
244
245 assert!(report.warranty_days_remaining() > 3600); }
247
248 #[test]
249 fn happy_add_photos() {
250 let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
251
252 report.add_photo("/uploads/photo1.jpg".to_string());
253 report.add_photo("/uploads/photo2.jpg".to_string());
254
255 assert_eq!(report.photos.len(), 2);
256 }
257
258 #[test]
259 fn happy_set_cost_replaces_the_amount() {
260 let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
261
262 report.set_cost(dec!(250.75)).expect("coût valide");
263
264 assert_eq!(report.cost, dec!(250.75));
265 }
266
267 #[test]
272 fn edge_zero_cost_is_accepted() {
273 let report = make(Decimal::ZERO, WarrantyType::None).expect("zéro est un coût valide");
274 assert_eq!(report.cost, Decimal::ZERO);
275 }
276
277 #[test]
278 fn edge_set_cost_to_zero_is_accepted() {
279 let mut report = make(dec!(10.00), WarrantyType::None).expect("coût valide");
280 report
281 .set_cost(Decimal::ZERO)
282 .expect("zéro est un coût valide");
283 assert_eq!(report.cost, Decimal::ZERO);
284 }
285
286 #[test]
289 fn edge_minus_one_cent_is_rejected() {
290 assert_eq!(
291 make(dec!(-0.01), WarrantyType::None).unwrap_err(),
292 WorkReportError::NegativeCost
293 );
294 }
295
296 #[test]
299 fn edge_decimal_arithmetic_is_exact() {
300 let mut report = make(dec!(0.10), WarrantyType::None).expect("coût valide");
301 report
302 .set_cost(report.cost + dec!(0.20))
303 .expect("coût valide");
304
305 assert_eq!(report.cost, dec!(0.30));
306 }
307
308 #[test]
311 fn negative_new_rejects_negative_cost() {
312 assert_eq!(
313 make(dec!(-1.00), WarrantyType::Standard).unwrap_err(),
314 WorkReportError::NegativeCost
315 );
316 }
317
318 #[test]
319 fn negative_set_cost_rejects_negative_cost() {
320 let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
321
322 assert_eq!(
323 report.set_cost(dec!(-0.01)).unwrap_err(),
324 WorkReportError::NegativeCost
325 );
326 }
327
328 #[test]
330 fn negative_rejected_set_cost_leaves_the_entity_untouched() {
331 let mut report = make(dec!(100.00), WarrantyType::None).expect("coût valide");
332 let before = report.updated_at;
333
334 let _ = report.set_cost(dec!(-5.00));
335
336 assert_eq!(report.cost, dec!(100.00));
337 assert_eq!(report.updated_at, before);
338 }
339
340 #[test]
347 fn security_negative_cost_cannot_bypass_the_domain() {
348 assert!(make(dec!(-999999.99), WarrantyType::Decennial).is_err());
349
350 let mut report = make(dec!(1.00), WarrantyType::None).expect("coût valide");
351 assert!(report.set_cost(dec!(-999999.99)).is_err());
352 assert_eq!(report.cost, dec!(1.00));
353 }
354}