koprogo_api/domain/entities/
contractor_report.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub enum ContractorReportStatus {
11 Draft,
13 Submitted,
15 UnderReview,
17 Validated,
19 Rejected,
21 RequiresCorrection,
23}
24
25impl ContractorReportStatus {
26 pub fn from_db_string(s: &str) -> Result<Self, String> {
27 match s {
28 "draft" => Ok(Self::Draft),
29 "submitted" => Ok(Self::Submitted),
30 "under_review" => Ok(Self::UnderReview),
31 "validated" => Ok(Self::Validated),
32 "rejected" => Ok(Self::Rejected),
33 "requires_correction" => Ok(Self::RequiresCorrection),
34 _ => Err(format!("Unknown contractor_report_status: {}", s)),
35 }
36 }
37
38 pub fn to_db_str(&self) -> &'static str {
39 match self {
40 Self::Draft => "draft",
41 Self::Submitted => "submitted",
42 Self::UnderReview => "under_review",
43 Self::Validated => "validated",
44 Self::Rejected => "rejected",
45 Self::RequiresCorrection => "requires_correction",
46 }
47 }
48
49 pub fn is_terminal(&self) -> bool {
50 matches!(self, Self::Validated | Self::Rejected)
51 }
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56pub struct ReplacedPart {
57 pub name: String,
58 pub reference: Option<String>,
59 pub quantity: u32,
60 pub photo_document_id: Option<Uuid>,
61}
62
63#[derive(Debug, Clone)]
70pub struct ContractorReport {
71 pub id: Uuid,
72 pub organization_id: Uuid,
73 pub building_id: Uuid,
74
75 pub ticket_id: Option<Uuid>,
77 pub quote_id: Option<Uuid>,
78
79 pub contractor_user_id: Option<Uuid>,
81 pub contractor_name: String,
82
83 pub work_date: Option<DateTime<Utc>>,
85
86 pub compte_rendu: Option<String>,
88
89 pub photos_before: Vec<Uuid>,
91 pub photos_after: Vec<Uuid>,
93 pub parts_replaced: Vec<ReplacedPart>,
95
96 pub status: ContractorReportStatus,
98
99 pub magic_token_hash: Option<String>,
101 pub magic_token_expires_at: Option<DateTime<Utc>>,
102
103 pub submitted_at: Option<DateTime<Utc>>,
105 pub validated_at: Option<DateTime<Utc>>,
107 pub validated_by: Option<Uuid>,
108 pub review_comments: Option<String>,
110
111 pub created_at: DateTime<Utc>,
112 pub updated_at: DateTime<Utc>,
113}
114
115impl ContractorReport {
116 pub fn new(
117 organization_id: Uuid,
118 building_id: Uuid,
119 contractor_name: String,
120 ticket_id: Option<Uuid>,
121 quote_id: Option<Uuid>,
122 contractor_user_id: Option<Uuid>,
123 ) -> Result<Self, String> {
124 if contractor_name.trim().is_empty() {
125 return Err("Le nom du prestataire est obligatoire".to_string());
126 }
127 if ticket_id.is_none() && quote_id.is_none() {
128 return Err("Un rapport doit être lié à un ticket ou à un devis".to_string());
129 }
130 let now = Utc::now();
131 Ok(Self {
132 id: Uuid::new_v4(),
133 organization_id,
134 building_id,
135 ticket_id,
136 quote_id,
137 contractor_user_id,
138 contractor_name,
139 work_date: None,
140 compte_rendu: None,
141 photos_before: vec![],
142 photos_after: vec![],
143 parts_replaced: vec![],
144 status: ContractorReportStatus::Draft,
145 magic_token_hash: None,
146 magic_token_expires_at: None,
147 submitted_at: None,
148 validated_at: None,
149 validated_by: None,
150 review_comments: None,
151 created_at: now,
152 updated_at: now,
153 })
154 }
155
156 pub fn submit(&mut self) -> Result<(), String> {
158 if self.status != ContractorReportStatus::Draft
159 && self.status != ContractorReportStatus::RequiresCorrection
160 {
161 return Err(format!(
162 "Impossible de soumettre depuis l'état {:?}",
163 self.status
164 ));
165 }
166 if self.compte_rendu.as_deref().unwrap_or("").trim().is_empty() {
167 return Err(
168 "Le champ compte_rendu est obligatoire pour soumettre le rapport".to_string(),
169 );
170 }
171 self.status = ContractorReportStatus::Submitted;
172 self.submitted_at = Some(Utc::now());
173 self.updated_at = Utc::now();
174 Ok(())
175 }
176
177 pub fn start_review(&mut self) -> Result<(), String> {
179 if self.status != ContractorReportStatus::Submitted {
180 return Err(format!(
181 "Impossible de mettre en révision depuis l'état {:?}",
182 self.status
183 ));
184 }
185 self.status = ContractorReportStatus::UnderReview;
186 self.updated_at = Utc::now();
187 Ok(())
188 }
189
190 pub fn validate(&mut self, validated_by: Uuid) -> Result<(), String> {
192 if self.status != ContractorReportStatus::Submitted
193 && self.status != ContractorReportStatus::UnderReview
194 {
195 return Err(format!(
196 "Impossible de valider depuis l'état {:?}",
197 self.status
198 ));
199 }
200 self.status = ContractorReportStatus::Validated;
201 self.validated_at = Some(Utc::now());
202 self.validated_by = Some(validated_by);
203 self.updated_at = Utc::now();
204 Ok(())
205 }
206
207 pub fn request_corrections(&mut self, comments: String) -> Result<(), String> {
209 if self.status != ContractorReportStatus::Submitted
210 && self.status != ContractorReportStatus::UnderReview
211 {
212 return Err(format!(
213 "Impossible de demander des corrections depuis l'état {:?}",
214 self.status
215 ));
216 }
217 if comments.trim().is_empty() {
218 return Err("Les commentaires de correction sont obligatoires".to_string());
219 }
220 self.status = ContractorReportStatus::RequiresCorrection;
221 self.review_comments = Some(comments);
222 self.updated_at = Utc::now();
223 Ok(())
224 }
225
226 pub fn reject(&mut self, comments: String, rejected_by: Uuid) -> Result<(), String> {
228 if self.status.is_terminal() {
229 return Err(format!(
230 "Impossible de rejeter depuis l'état terminal {:?}",
231 self.status
232 ));
233 }
234 self.status = ContractorReportStatus::Rejected;
235 self.review_comments = Some(comments);
236 self.validated_by = Some(rejected_by);
237 self.updated_at = Utc::now();
238 Ok(())
239 }
240
241 pub fn is_magic_token_valid(&self) -> bool {
243 match &self.magic_token_expires_at {
244 Some(exp) => *exp > Utc::now(),
245 None => false,
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn make_report() -> ContractorReport {
255 let mut r = ContractorReport::new(
256 Uuid::new_v4(),
257 Uuid::new_v4(),
258 "Martin Plomberie SPRL".to_string(),
259 Some(Uuid::new_v4()),
260 None,
261 None,
262 )
263 .unwrap();
264 r.compte_rendu = Some("Travaux effectués conformément au devis".to_string());
265 r
266 }
267
268 #[test]
269 fn test_new_report_success() {
270 let r = make_report();
271 assert_eq!(r.status, ContractorReportStatus::Draft);
272 assert!(r.photos_before.is_empty());
273 }
274
275 #[test]
276 fn test_new_requires_ticket_or_quote() {
277 let err = ContractorReport::new(
278 Uuid::new_v4(),
279 Uuid::new_v4(),
280 "Test".to_string(),
281 None,
282 None,
283 None,
284 );
285 assert!(err.is_err());
286 }
287
288 #[test]
289 fn test_new_requires_contractor_name() {
290 let err = ContractorReport::new(
291 Uuid::new_v4(),
292 Uuid::new_v4(),
293 " ".to_string(),
294 Some(Uuid::new_v4()),
295 None,
296 None,
297 );
298 assert!(err.is_err());
299 }
300
301 #[test]
302 fn test_submit_from_draft() {
303 let mut r = make_report();
304 r.submit().unwrap();
305 assert_eq!(r.status, ContractorReportStatus::Submitted);
306 assert!(r.submitted_at.is_some());
307 }
308
309 #[test]
310 fn test_submit_from_requires_correction() {
311 let mut r = make_report();
312 r.submit().unwrap();
313 r.request_corrections("Manque photos avant".to_string())
314 .unwrap();
315 r.submit().unwrap();
316 assert_eq!(r.status, ContractorReportStatus::Submitted);
317 }
318
319 #[test]
320 fn test_validate_from_submitted() {
321 let mut r = make_report();
322 let cdc_id = Uuid::new_v4();
323 r.submit().unwrap();
324 r.validate(cdc_id).unwrap();
325 assert_eq!(r.status, ContractorReportStatus::Validated);
326 assert_eq!(r.validated_by, Some(cdc_id));
327 }
328
329 #[test]
330 fn test_validate_from_under_review() {
331 let mut r = make_report();
332 let cdc_id = Uuid::new_v4();
333 r.submit().unwrap();
334 r.start_review().unwrap();
335 r.validate(cdc_id).unwrap();
336 assert_eq!(r.status, ContractorReportStatus::Validated);
337 }
338
339 #[test]
340 fn test_cannot_validate_from_draft() {
341 let mut r = make_report();
342 assert!(r.validate(Uuid::new_v4()).is_err());
343 }
344
345 #[test]
346 fn test_request_corrections_requires_comment() {
347 let mut r = make_report();
348 r.submit().unwrap();
349 assert!(r.request_corrections(" ".to_string()).is_err());
350 }
351
352 #[test]
353 fn test_request_corrections_ok() {
354 let mut r = make_report();
355 r.submit().unwrap();
356 r.request_corrections("Ajoutez les photos après travaux".to_string())
357 .unwrap();
358 assert_eq!(r.status, ContractorReportStatus::RequiresCorrection);
359 assert!(r.review_comments.is_some());
360 }
361
362 #[test]
363 fn test_reject_from_submitted() {
364 let mut r = make_report();
365 r.submit().unwrap();
366 r.reject("Travaux non conformes".to_string(), Uuid::new_v4())
367 .unwrap();
368 assert_eq!(r.status, ContractorReportStatus::Rejected);
369 }
370
371 #[test]
372 fn test_cannot_reject_validated() {
373 let mut r = make_report();
374 r.submit().unwrap();
375 r.validate(Uuid::new_v4()).unwrap();
376 assert!(r.reject("Non".to_string(), Uuid::new_v4()).is_err());
377 }
378
379 #[test]
380 fn test_magic_token_expired() {
381 let mut r = make_report();
382 r.magic_token_expires_at = Some(Utc::now() - chrono::Duration::hours(1));
383 assert!(!r.is_magic_token_valid());
384 }
385
386 #[test]
387 fn test_status_db_roundtrip() {
388 let statuses = [
389 ContractorReportStatus::Draft,
390 ContractorReportStatus::Submitted,
391 ContractorReportStatus::UnderReview,
392 ContractorReportStatus::Validated,
393 ContractorReportStatus::Rejected,
394 ContractorReportStatus::RequiresCorrection,
395 ];
396 for s in &statuses {
397 let db = s.to_db_str();
398 let back = ContractorReportStatus::from_db_string(db).unwrap();
399 assert_eq!(s, &back);
400 }
401 }
402}