1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub enum AgeRequestStatus {
12 Draft,
14 Open,
16 Reached,
18 Submitted,
20 Accepted,
22 Expired,
24 Rejected,
26 Withdrawn,
28}
29
30impl AgeRequestStatus {
31 pub fn from_db_string(s: &str) -> Result<Self, String> {
32 match s {
33 "draft" => Ok(Self::Draft),
34 "open" => Ok(Self::Open),
35 "reached" => Ok(Self::Reached),
36 "submitted" => Ok(Self::Submitted),
37 "accepted" => Ok(Self::Accepted),
38 "expired" => Ok(Self::Expired),
39 "rejected" => Ok(Self::Rejected),
40 "withdrawn" => Ok(Self::Withdrawn),
41 _ => Err(format!("Unknown age_request_status: {}", s)),
42 }
43 }
44
45 pub fn to_db_str(&self) -> &'static str {
46 match self {
47 Self::Draft => "draft",
48 Self::Open => "open",
49 Self::Reached => "reached",
50 Self::Submitted => "submitted",
51 Self::Accepted => "accepted",
52 Self::Expired => "expired",
53 Self::Rejected => "rejected",
54 Self::Withdrawn => "withdrawn",
55 }
56 }
57
58 pub fn is_terminal(&self) -> bool {
60 matches!(
61 self,
62 Self::Accepted | Self::Expired | Self::Rejected | Self::Withdrawn
63 )
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct AgeRequestCosignatory {
70 pub id: Uuid,
71 pub age_request_id: Uuid,
72 pub owner_id: Uuid,
73 pub shares_pct: f64,
75 pub signed_at: DateTime<Utc>,
76}
77
78impl AgeRequestCosignatory {
79 pub fn new(age_request_id: Uuid, owner_id: Uuid, shares_pct: f64) -> Result<Self, String> {
80 if shares_pct <= 0.0 || shares_pct > 1.0 {
81 return Err(format!(
82 "shares_pct doit être entre 0 et 1, reçu: {}",
83 shares_pct
84 ));
85 }
86 Ok(Self {
87 id: Uuid::new_v4(),
88 age_request_id,
89 owner_id,
90 shares_pct,
91 signed_at: Utc::now(),
92 })
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub struct AgeRequest {
108 pub id: Uuid,
109 pub organization_id: Uuid,
110 pub building_id: Uuid,
111
112 pub title: String,
114
115 pub description: Option<String>,
117
118 pub status: AgeRequestStatus,
120
121 pub created_by: Uuid,
123
124 pub cosignatories: Vec<AgeRequestCosignatory>,
126
127 pub total_shares_pct: f64,
129
130 pub threshold_pct: f64,
132
133 pub threshold_reached: bool,
135
136 pub threshold_reached_at: Option<DateTime<Utc>>,
138
139 pub submitted_to_syndic_at: Option<DateTime<Utc>>,
141
142 pub syndic_deadline_at: Option<DateTime<Utc>>,
144
145 pub syndic_response_at: Option<DateTime<Utc>>,
147
148 pub syndic_notes: Option<String>,
150
151 pub auto_convocation_triggered: bool,
153
154 pub meeting_id: Option<Uuid>,
156
157 pub concertation_poll_id: Option<Uuid>,
159
160 pub created_at: DateTime<Utc>,
161 pub updated_at: DateTime<Utc>,
162}
163
164impl AgeRequest {
165 pub const SYNDIC_DEADLINE_DAYS: i64 = 15;
167
168 pub const DEFAULT_THRESHOLD_PCT: f64 = 0.20;
170
171 pub fn new(
173 organization_id: Uuid,
174 building_id: Uuid,
175 title: String,
176 description: Option<String>,
177 created_by: Uuid,
178 ) -> Result<Self, String> {
179 if title.trim().is_empty() {
180 return Err("Le titre de la demande d'AGE ne peut pas être vide".to_string());
181 }
182 if title.len() > 255 {
183 return Err("Le titre ne peut pas dépasser 255 caractères".to_string());
184 }
185
186 let now = Utc::now();
187 Ok(Self {
188 id: Uuid::new_v4(),
189 organization_id,
190 building_id,
191 title: title.trim().to_string(),
192 description,
193 status: AgeRequestStatus::Draft,
194 created_by,
195 cosignatories: Vec::new(),
196 total_shares_pct: 0.0,
197 threshold_pct: Self::DEFAULT_THRESHOLD_PCT,
198 threshold_reached: false,
199 threshold_reached_at: None,
200 submitted_to_syndic_at: None,
201 syndic_deadline_at: None,
202 syndic_response_at: None,
203 syndic_notes: None,
204 auto_convocation_triggered: false,
205 meeting_id: None,
206 concertation_poll_id: None,
207 created_at: now,
208 updated_at: now,
209 })
210 }
211
212 pub fn open(&mut self) -> Result<(), String> {
214 if self.status != AgeRequestStatus::Draft {
215 return Err(format!(
216 "Impossible d'ouvrir une demande en statut {:?}",
217 self.status
218 ));
219 }
220 self.status = AgeRequestStatus::Open;
221 self.updated_at = Utc::now();
222 Ok(())
223 }
224
225 pub fn add_cosignatory(&mut self, owner_id: Uuid, shares_pct: f64) -> Result<bool, String> {
228 if self.status != AgeRequestStatus::Draft && self.status != AgeRequestStatus::Open {
229 return Err(format!(
230 "Impossible d'ajouter un cosignataire en statut {:?}",
231 self.status
232 ));
233 }
234
235 if self.cosignatories.iter().any(|c| c.owner_id == owner_id) {
237 return Err("Ce copropriétaire a déjà signé cette demande".to_string());
238 }
239
240 let cosignatory = AgeRequestCosignatory::new(self.id, owner_id, shares_pct)?;
241 self.cosignatories.push(cosignatory);
242
243 self.total_shares_pct = self.cosignatories.iter().map(|c| c.shares_pct).sum();
245 self.updated_at = Utc::now();
246
247 let newly_reached = !self.threshold_reached && self.total_shares_pct >= self.threshold_pct;
249
250 if newly_reached {
251 self.threshold_reached = true;
252 self.threshold_reached_at = Some(Utc::now());
253 self.status = AgeRequestStatus::Reached;
254 }
255
256 Ok(newly_reached)
257 }
258
259 pub fn remove_cosignatory(&mut self, owner_id: Uuid) -> Result<(), String> {
261 if self.status != AgeRequestStatus::Draft
262 && self.status != AgeRequestStatus::Open
263 && self.status != AgeRequestStatus::Reached
264 {
265 return Err(format!(
266 "Impossible de retirer un cosignataire en statut {:?}",
267 self.status
268 ));
269 }
270
271 let before_len = self.cosignatories.len();
272 self.cosignatories.retain(|c| c.owner_id != owner_id);
273
274 if self.cosignatories.len() == before_len {
275 return Err("Ce copropriétaire n'a pas signé cette demande".to_string());
276 }
277
278 self.total_shares_pct = self.cosignatories.iter().map(|c| c.shares_pct).sum();
280 self.updated_at = Utc::now();
281
282 if self.threshold_reached && self.total_shares_pct < self.threshold_pct {
284 self.threshold_reached = false;
285 self.threshold_reached_at = None;
286 self.status = AgeRequestStatus::Open; }
288
289 Ok(())
290 }
291
292 pub fn submit_to_syndic(&mut self) -> Result<(), String> {
294 if self.status != AgeRequestStatus::Reached {
295 return Err(format!(
296 "La demande doit être en statut Reached pour être soumise (statut actuel: {:?}). \
297 Le seuil d'1/5 des quotes-parts doit être atteint.",
298 self.status
299 ));
300 }
301
302 let now = Utc::now();
303 self.status = AgeRequestStatus::Submitted;
304 self.submitted_to_syndic_at = Some(now);
305 self.syndic_deadline_at = Some(now + Duration::days(Self::SYNDIC_DEADLINE_DAYS));
306 self.updated_at = now;
307 Ok(())
308 }
309
310 pub fn accept_by_syndic(&mut self, notes: Option<String>) -> Result<(), String> {
312 if self.status != AgeRequestStatus::Submitted {
313 return Err(format!(
314 "La demande doit être en statut Submitted pour être acceptée (statut actuel: {:?})",
315 self.status
316 ));
317 }
318 let now = Utc::now();
319 self.status = AgeRequestStatus::Accepted;
320 self.syndic_response_at = Some(now);
321 self.syndic_notes = notes;
322 self.updated_at = now;
323 Ok(())
324 }
325
326 pub fn reject_by_syndic(&mut self, reason: String) -> Result<(), String> {
328 if self.status != AgeRequestStatus::Submitted {
329 return Err(format!(
330 "La demande doit être en statut Submitted pour être rejetée (statut actuel: {:?})",
331 self.status
332 ));
333 }
334 if reason.trim().is_empty() {
335 return Err("Un motif de refus est obligatoire".to_string());
336 }
337 let now = Utc::now();
338 self.status = AgeRequestStatus::Rejected;
339 self.syndic_response_at = Some(now);
340 self.syndic_notes = Some(reason);
341 self.updated_at = now;
342 Ok(())
343 }
344
345 pub fn trigger_auto_convocation(&mut self) -> Result<(), String> {
347 if self.status != AgeRequestStatus::Submitted {
348 return Err(format!(
349 "La demande doit être en statut Submitted (statut actuel: {:?})",
350 self.status
351 ));
352 }
353
354 if let Some(deadline) = self.syndic_deadline_at {
356 if Utc::now() < deadline {
357 return Err(format!(
358 "Le délai syndic n'est pas encore dépassé (expire le {})",
359 deadline.format("%d/%m/%Y")
360 ));
361 }
362 }
363
364 self.status = AgeRequestStatus::Expired;
365 self.auto_convocation_triggered = true;
366 self.updated_at = Utc::now();
367 Ok(())
368 }
369
370 pub fn withdraw(&mut self, requester_id: Uuid) -> Result<(), String> {
372 if self.status.is_terminal() {
373 return Err(format!(
374 "Impossible de retirer une demande en statut {:?}",
375 self.status
376 ));
377 }
378 if self.created_by != requester_id {
380 return Err("Seul l'initiateur peut retirer cette demande".to_string());
381 }
382 self.status = AgeRequestStatus::Withdrawn;
383 self.updated_at = Utc::now();
384 Ok(())
385 }
386
387 pub fn set_meeting(&mut self, meeting_id: Uuid) {
389 self.meeting_id = Some(meeting_id);
390 self.updated_at = Utc::now();
391 }
392
393 pub fn set_concertation_poll(&mut self, poll_id: Uuid) {
395 self.concertation_poll_id = Some(poll_id);
396 self.updated_at = Utc::now();
397 }
398
399 pub fn is_deadline_expired(&self) -> bool {
401 self.syndic_deadline_at
402 .map(|d| Utc::now() > d)
403 .unwrap_or(false)
404 }
405
406 pub fn shares_pct_missing(&self) -> f64 {
408 if self.threshold_reached {
409 0.0
410 } else {
411 (self.threshold_pct - self.total_shares_pct).max(0.0)
412 }
413 }
414
415 pub fn calculate_progress_percentage(&self, _building_total_shares: f64) -> f64 {
426 let progress = (self.total_shares_pct / self.threshold_pct) * 100.0;
428 progress.min(100.0) }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 fn make_request() -> AgeRequest {
437 AgeRequest::new(
438 Uuid::new_v4(),
439 Uuid::new_v4(),
440 "Remplacement toiture - AGE urgente".to_string(),
441 Some("La toiture présente des infiltrations importantes.".to_string()),
442 Uuid::new_v4(),
443 )
444 .unwrap()
445 }
446
447 #[test]
448 fn test_new_age_request_is_draft() {
449 let req = make_request();
450 assert_eq!(req.status, AgeRequestStatus::Draft);
451 assert_eq!(req.total_shares_pct, 0.0);
452 assert!(!req.threshold_reached);
453 assert_eq!(req.threshold_pct, AgeRequest::DEFAULT_THRESHOLD_PCT);
454 assert!(req.cosignatories.is_empty());
455 }
456
457 #[test]
458 fn test_empty_title_rejected() {
459 let result = AgeRequest::new(
460 Uuid::new_v4(),
461 Uuid::new_v4(),
462 " ".to_string(),
463 None,
464 Uuid::new_v4(),
465 );
466 assert!(result.is_err());
467 }
468
469 #[test]
470 fn test_open_transitions_from_draft() {
471 let mut req = make_request();
472 req.open().unwrap();
473 assert_eq!(req.status, AgeRequestStatus::Open);
474 }
475
476 #[test]
477 fn test_open_fails_if_not_draft() {
478 let mut req = make_request();
479 req.status = AgeRequestStatus::Reached;
480 assert!(req.open().is_err());
481 }
482
483 #[test]
484 fn test_add_cosignatory_accumulates_shares() {
485 let mut req = make_request();
486 req.open().unwrap();
487
488 let owner1 = Uuid::new_v4();
489 let newly_reached = req.add_cosignatory(owner1, 0.10).unwrap();
490 assert!(!newly_reached);
491 assert!((req.total_shares_pct - 0.10).abs() < 1e-9);
492 assert_eq!(req.status, AgeRequestStatus::Open);
493 }
494
495 #[test]
496 fn test_threshold_reached_at_20_percent() {
497 let mut req = make_request();
498 req.open().unwrap();
499
500 let o1 = Uuid::new_v4();
502 let reached = req.add_cosignatory(o1, 0.10).unwrap();
503 assert!(!reached);
504 assert_eq!(req.status, AgeRequestStatus::Open);
505
506 let o2 = Uuid::new_v4();
508 let reached = req.add_cosignatory(o2, 0.12).unwrap();
509 assert!(reached);
510 assert_eq!(req.status, AgeRequestStatus::Reached);
511 assert!(req.threshold_reached);
512 assert!(req.threshold_reached_at.is_some());
513 assert!((req.total_shares_pct - 0.22).abs() < 1e-9);
514 }
515
516 #[test]
517 fn test_duplicate_cosignatory_rejected() {
518 let mut req = make_request();
519 req.open().unwrap();
520 let owner = Uuid::new_v4();
521 req.add_cosignatory(owner, 0.10).unwrap();
522 let result = req.add_cosignatory(owner, 0.05);
523 assert!(result.is_err());
524 }
525
526 #[test]
527 fn test_remove_cosignatory_reverts_status() {
528 let mut req = make_request();
529 req.open().unwrap();
530
531 let o1 = Uuid::new_v4();
532 let o2 = Uuid::new_v4();
533 req.add_cosignatory(o1, 0.15).unwrap();
534 req.add_cosignatory(o2, 0.10).unwrap(); assert_eq!(req.status, AgeRequestStatus::Reached);
537
538 req.remove_cosignatory(o2).unwrap();
540 assert_eq!(req.status, AgeRequestStatus::Open);
541 assert!(!req.threshold_reached);
542 }
543
544 #[test]
545 fn test_submit_to_syndic() {
546 let mut req = make_request();
547 req.open().unwrap();
548 let o1 = Uuid::new_v4();
549 req.add_cosignatory(o1, 0.25).unwrap(); req.submit_to_syndic().unwrap();
552 assert_eq!(req.status, AgeRequestStatus::Submitted);
553 assert!(req.submitted_to_syndic_at.is_some());
554 assert!(req.syndic_deadline_at.is_some());
555
556 let diff = req.syndic_deadline_at.unwrap() - req.submitted_to_syndic_at.unwrap();
558 assert_eq!(diff.num_days(), AgeRequest::SYNDIC_DEADLINE_DAYS);
559 }
560
561 #[test]
562 fn test_submit_fails_if_not_reached() {
563 let mut req = make_request();
564 req.open().unwrap();
565 assert!(req.submit_to_syndic().is_err());
567 }
568
569 #[test]
570 fn test_accept_by_syndic() {
571 let mut req = make_request();
572 req.open().unwrap();
573 req.add_cosignatory(Uuid::new_v4(), 0.25).unwrap();
574 req.submit_to_syndic().unwrap();
575 req.accept_by_syndic(Some("Convocation dans 3 semaines".to_string()))
576 .unwrap();
577 assert_eq!(req.status, AgeRequestStatus::Accepted);
578 assert!(req.syndic_response_at.is_some());
579 }
580
581 #[test]
582 fn test_reject_requires_reason() {
583 let mut req = make_request();
584 req.open().unwrap();
585 req.add_cosignatory(Uuid::new_v4(), 0.25).unwrap();
586 req.submit_to_syndic().unwrap();
587 assert!(req.reject_by_syndic(" ".to_string()).is_err());
588 req.reject_by_syndic("Demande insuffisamment motivée".to_string())
589 .unwrap();
590 assert_eq!(req.status, AgeRequestStatus::Rejected);
591 }
592
593 #[test]
594 fn test_withdraw_by_initiator_only() {
595 let mut req = make_request();
596 req.open().unwrap();
597
598 let other = Uuid::new_v4();
599 assert!(req.withdraw(other).is_err());
600
601 let initiator = req.created_by;
602 req.withdraw(initiator).unwrap();
603 assert_eq!(req.status, AgeRequestStatus::Withdrawn);
604 }
605
606 #[test]
607 fn test_shares_pct_missing() {
608 let mut req = make_request();
609 req.open().unwrap();
610
611 assert!((req.shares_pct_missing() - 0.20).abs() < 1e-9);
613
614 req.add_cosignatory(Uuid::new_v4(), 0.12).unwrap();
615 assert!((req.shares_pct_missing() - 0.08).abs() < 1e-9);
617
618 req.add_cosignatory(Uuid::new_v4(), 0.10).unwrap();
619 assert_eq!(req.shares_pct_missing(), 0.0);
621 }
622
623 #[test]
624 fn test_status_is_terminal() {
625 assert!(AgeRequestStatus::Accepted.is_terminal());
626 assert!(AgeRequestStatus::Expired.is_terminal());
627 assert!(AgeRequestStatus::Rejected.is_terminal());
628 assert!(AgeRequestStatus::Withdrawn.is_terminal());
629 assert!(!AgeRequestStatus::Draft.is_terminal());
630 assert!(!AgeRequestStatus::Open.is_terminal());
631 assert!(!AgeRequestStatus::Reached.is_terminal());
632 assert!(!AgeRequestStatus::Submitted.is_terminal());
633 }
634
635 #[test]
636 fn test_calculate_progress_percentage() {
637 let mut req = make_request();
638 req.open().unwrap();
639
640 assert_eq!(req.calculate_progress_percentage(1.0), 0.0);
642
643 req.add_cosignatory(Uuid::new_v4(), 0.05).unwrap();
645 assert!((req.calculate_progress_percentage(1.0) - 25.0).abs() < 1e-9);
646
647 req.add_cosignatory(Uuid::new_v4(), 0.05).unwrap();
649 assert!((req.calculate_progress_percentage(1.0) - 50.0).abs() < 1e-9);
650
651 req.add_cosignatory(Uuid::new_v4(), 0.10).unwrap();
653 assert_eq!(req.calculate_progress_percentage(1.0), 100.0);
654 }
655}