Skip to main content

koprogo_api/domain/copropriete/
evaluation_des_contrats.rs

1//! Le rapport annuel d'évaluation des contrats de fournitures.
2//!
3//! Art. 3.89 § 5, 12° — le syndic est chargé :
4//!
5//! > « de soumettre à l'**assemblée générale ordinaire** un **rapport
6//! > d'évaluation** des **contrats de fournitures régulières**. »
7//!
8//! Une phrase, trois contraintes, et chacune se perd facilement :
9//!
10//! 1. **à l'assemblée générale ordinaire** — pas à une extraordinaire, pas au
11//!    conseil de copropriété. C'est devant l'assemblée annuelle que les
12//!    copropriétaires jugent les contrats qu'ils paient ;
13//! 2. **annuellement** — chaque AGO, sans reconduction ni report. Un rapport
14//!    présenté en 2025 ne couvre pas l'exercice 2026 ;
15//! 3. **les contrats de fournitures régulières** — l'entretien de l'ascenseur,
16//!    le nettoyage, le chauffage, l'assurance. Pas les marchés ponctuels : une
17//!    réfection de toiture n'est pas une fourniture régulière, et l'y inclure
18//!    noierait le rapport sous des lignes qui n'appellent aucune décision de
19//!    reconduction.
20//!
21//! L'obligation existe parce que ces contrats se reconduisent tacitement.
22//! Sans un point annuel, une copropriété paie pendant dix ans un contrat
23//! d'entretien que personne n'a rediscuté — et c'est exactement ce que
24//! l'article veut empêcher.
25//!
26//! Elle prolonge l'Art. 3.88 § 1er, 1°, c), qui fait voter le seuil au-delà
27//! duquel la mise en concurrence est obligatoire : l'un fixe la règle, l'autre
28//! oblige à regarder chaque année si elle est tenue.
29//!
30//! Voir issue #581.
31
32use uuid::Uuid;
33
34/// La nature d'un engagement de l'ACP, du point de vue de cette obligation.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum NatureDuContrat {
37    /// Fourniture régulière : entretien, nettoyage, chauffage, assurance.
38    /// C'est ce que le rapport doit couvrir.
39    FournitureReguliere,
40    /// Marché ponctuel : des travaux, une expertise. Hors du rapport.
41    MarchePonctuel,
42}
43
44/// Un contrat de l'ACP, tel que l'évaluation le voit.
45#[derive(Debug, Clone, PartialEq)]
46pub struct ContratDeLacp {
47    pub id: Uuid,
48    pub objet: String,
49    pub nature: NatureDuContrat,
50    /// Le fournisseur, pour que le rapport soit nominatif.
51    pub fournisseur: String,
52}
53
54/// Ce qui manque au rapport d'un exercice.
55#[derive(Debug, Clone, PartialEq)]
56pub enum EvaluationManquante {
57    /// Aucun rapport n'a été soumis pour cet exercice.
58    RapportAbsent { exercice: i32 },
59    /// Des contrats de fournitures régulières n'y figurent pas.
60    ContratsNonEvalues { objets: Vec<String> },
61}
62
63impl std::fmt::Display for EvaluationManquante {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            Self::RapportAbsent { exercice } => write!(
67                f,
68                "Art. 3.89 § 5, 12° : aucun rapport d'évaluation des contrats de fournitures \
69                 régulières soumis à l'AGO de l'exercice {exercice}."
70            ),
71            Self::ContratsNonEvalues { objets } => write!(
72                f,
73                "Art. 3.89 § 5, 12° : {} contrat(s) de fournitures régulières absent(s) du \
74                 rapport — {}. Ces contrats se reconduisent tacitement ; sans point annuel, \
75                 personne ne les rediscute.",
76                objets.len(),
77                objets.join(", ")
78            ),
79        }
80    }
81}
82
83/// Le rapport soumis à une assemblée générale ordinaire.
84#[derive(Debug, Clone, PartialEq)]
85pub struct RapportDevaluation {
86    pub exercice: i32,
87    /// L'AGO devant laquelle il a été soumis.
88    ///
89    /// Obligatoire : le soumettre au conseil de copropriété ou à une
90    /// extraordinaire ne satisfait pas l'article.
91    pub ago_id: Uuid,
92    /// Les contrats effectivement évalués.
93    pub contrats_evalues: Vec<Uuid>,
94}
95
96/// Vérifie qu'un exercice a bien son rapport, et qu'il est complet.
97///
98/// `contrats` sont tous les engagements en cours de l'ACP ; seules les
99/// fournitures régulières sont attendues au rapport.
100pub fn verifier_exercice(
101    exercice: i32,
102    contrats: &[ContratDeLacp],
103    rapports: &[RapportDevaluation],
104) -> Result<(), EvaluationManquante> {
105    let Some(rapport) = rapports.iter().find(|r| r.exercice == exercice) else {
106        return Err(EvaluationManquante::RapportAbsent { exercice });
107    };
108
109    let oublies: Vec<String> = contrats
110        .iter()
111        .filter(|c| c.nature == NatureDuContrat::FournitureReguliere)
112        .filter(|c| !rapport.contrats_evalues.contains(&c.id))
113        .map(|c| format!("{} ({})", c.objet, c.fournisseur))
114        .collect();
115
116    if oublies.is_empty() {
117        Ok(())
118    } else {
119        Err(EvaluationManquante::ContratsNonEvalues { objets: oublies })
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    fn contrat(objet: &str, nature: NatureDuContrat) -> ContratDeLacp {
128        ContratDeLacp {
129            id: Uuid::new_v4(),
130            objet: objet.to_string(),
131            nature,
132            fournisseur: "Kone SA".to_string(),
133        }
134    }
135
136    #[test]
137    fn happy_un_rapport_complet_satisfait_lobligation() {
138        let ascenseur = contrat("Entretien ascenseur", NatureDuContrat::FournitureReguliere);
139        let nettoyage = contrat("Nettoyage communs", NatureDuContrat::FournitureReguliere);
140        let rapports = vec![RapportDevaluation {
141            exercice: 2026,
142            ago_id: Uuid::new_v4(),
143            contrats_evalues: vec![ascenseur.id, nettoyage.id],
144        }];
145
146        assert!(verifier_exercice(2026, &[ascenseur, nettoyage], &rapports).is_ok());
147    }
148
149    #[test]
150    fn negative_sans_rapport_lexercice_est_en_defaut() {
151        assert_eq!(
152            verifier_exercice(2026, &[], &[]),
153            Err(EvaluationManquante::RapportAbsent { exercice: 2026 })
154        );
155    }
156
157    /// @security — un contrat oublié est un contrat que personne ne rediscute.
158    ///
159    /// C'est précisément ce que l'article veut empêcher : la reconduction
160    /// tacite pendant dix ans d'un entretien que plus personne n'examine.
161    #[test]
162    fn security_un_contrat_oublie_est_signale_nominativement() {
163        let ascenseur = contrat("Entretien ascenseur", NatureDuContrat::FournitureReguliere);
164        let chauffage = contrat("Entretien chaudière", NatureDuContrat::FournitureReguliere);
165        let rapports = vec![RapportDevaluation {
166            exercice: 2026,
167            ago_id: Uuid::new_v4(),
168            contrats_evalues: vec![ascenseur.id],
169        }];
170
171        match verifier_exercice(2026, &[ascenseur, chauffage], &rapports) {
172            Err(EvaluationManquante::ContratsNonEvalues { objets }) => {
173                assert_eq!(objets.len(), 1);
174                assert!(
175                    objets[0].contains("Entretien chaudière") && objets[0].contains("Kone SA"),
176                    "le rapport doit nommer le contrat ET son fournisseur : {objets:?}"
177                );
178            }
179            autre => panic!("attendu un manquement : {autre:?}"),
180        }
181    }
182
183    /// Les marchés ponctuels ne sont pas attendus au rapport.
184    ///
185    /// Une réfection de toiture n'est pas une fourniture régulière, et l'y
186    /// inclure noierait le rapport sous des lignes qui n'appellent aucune
187    /// décision de reconduction.
188    #[test]
189    fn happy_un_marche_ponctuel_nest_pas_attendu_au_rapport() {
190        let toiture = contrat("Réfection toiture", NatureDuContrat::MarchePonctuel);
191        let rapports = vec![RapportDevaluation {
192            exercice: 2026,
193            ago_id: Uuid::new_v4(),
194            contrats_evalues: vec![],
195        }];
196
197        assert!(verifier_exercice(2026, &[toiture], &rapports).is_ok());
198    }
199
200    /// @security — pas de reconduction du rapport lui-même.
201    ///
202    /// « Soumettre à l'assemblée générale ordinaire » se lit chaque année. Un
203    /// rapport présenté en 2025 ne couvre pas l'exercice 2026.
204    #[test]
205    fn security_un_rapport_de_lexercice_precedent_ne_couvre_pas_le_suivant() {
206        let rapports = vec![RapportDevaluation {
207            exercice: 2025,
208            ago_id: Uuid::new_v4(),
209            contrats_evalues: vec![],
210        }];
211
212        assert_eq!(
213            verifier_exercice(2026, &[], &rapports),
214            Err(EvaluationManquante::RapportAbsent { exercice: 2026 })
215        );
216    }
217
218    #[test]
219    fn happy_une_acp_sans_contrat_regulier_na_rien_a_evaluer() {
220        let rapports = vec![RapportDevaluation {
221            exercice: 2026,
222            ago_id: Uuid::new_v4(),
223            contrats_evalues: vec![],
224        }];
225        assert!(verifier_exercice(2026, &[], &rapports).is_ok());
226    }
227}