1use crate::domain::entities::SyndicMandate;
17use chrono::{DateTime, Utc};
18use uuid::Uuid;
19
20pub trait PieceDeGestion {
25 fn acp_id(&self) -> Uuid;
27}
28
29pub fn perimetre_du_mandataire<'a>(
34 pieces: &'a [&'a dyn PieceDeGestion],
35 mandats: &[SyndicMandate],
36 syndic: Uuid,
37 moment: DateTime<Utc>,
38) -> Vec<&'a dyn PieceDeGestion> {
39 pieces
40 .iter()
41 .filter(|piece| {
42 let acp = piece.acp_id();
43 mandats
44 .iter()
45 .filter(|m| m.acp_id == acp)
46 .any(|m| m.covers(moment) && m.organization_id == syndic)
47 })
48 .copied()
49 .collect()
50}
51
52pub const DELAI_PASSATION_JOURS: i64 = 30;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum EtatPassation {
62 RemisATemps,
64 RemisEnRetard,
66 EnCours,
68 EnDefaut,
70}
71
72#[derive(Debug, Clone, PartialEq)]
87pub struct PassationDeDossier {
88 pub acp_id: Uuid,
89 pub syndic_sortant: Uuid,
90 pub syndic_entrant: Uuid,
91 pub fin_de_mandat: DateTime<Utc>,
93 pub echeance: DateTime<Utc>,
94 pub remis_le: Option<DateTime<Utc>>,
96}
97
98impl PassationDeDossier {
99 pub fn nouvelle(
100 acp_id: Uuid,
101 syndic_sortant: Uuid,
102 syndic_entrant: Uuid,
103 fin_de_mandat: DateTime<Utc>,
104 ) -> Self {
105 Self {
106 acp_id,
107 syndic_sortant,
108 syndic_entrant,
109 fin_de_mandat,
110 echeance: fin_de_mandat + chrono::Duration::days(DELAI_PASSATION_JOURS),
111 remis_le: None,
112 }
113 }
114
115 pub fn remettre(&mut self, le: DateTime<Utc>) {
116 self.remis_le = Some(le);
117 }
118
119 pub fn etat(&self, moment: DateTime<Utc>) -> EtatPassation {
120 match self.remis_le {
121 Some(remis) if remis <= self.echeance => EtatPassation::RemisATemps,
122 Some(_) => EtatPassation::RemisEnRetard,
123 None if moment <= self.echeance => EtatPassation::EnCours,
124 None => EtatPassation::EnDefaut,
125 }
126 }
127
128 pub fn jours_restants(&self, moment: DateTime<Utc>) -> i64 {
133 (self.echeance - moment).num_days()
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::domain::entities::{
141 Budget, CallForFunds, ContributionType, Convocation, ConvocationType, EtatDate,
142 EtatDateLanguage, Expense, ExpenseCategory, JournalEntry, JournalEntryLine, Meeting,
143 MeetingType, OwnerContribution, PaymentReminder, ReminderLevel,
144 };
145 use chrono::Duration;
146 use rust_decimal_macros::dec;
147
148 struct DossierComplet {
152 charge: Expense,
153 budget: Budget,
154 appel_de_fonds: CallForFunds,
155 quote_part: OwnerContribution,
156 ecriture: JournalEntry,
157 assemblee: Meeting,
158 convocation: Convocation,
159 etat_date: EtatDate,
160 relance: PaymentReminder,
161 }
162
163 impl DossierComplet {
164 fn pour(acp: Uuid, syndic: Uuid, immeuble: Uuid) -> Self {
165 Self {
166 charge: Expense::new(
167 acp,
168 syndic,
169 immeuble,
170 ExpenseCategory::Maintenance,
171 "Entretien de la chaudière".to_string(),
172 dec!(1200.00),
173 Utc::now(),
174 None,
175 None,
176 None,
177 )
178 .expect("charge valide"),
179 budget: Budget::new(acp, syndic, immeuble, 2026, dec!(48000.00), dec!(12000.00))
180 .expect("budget valide"),
181 appel_de_fonds: CallForFunds::new(
182 acp,
183 syndic,
184 immeuble,
185 "Provision T1 2026".to_string(),
186 "Charges ordinaires du premier trimestre".to_string(),
187 dec!(12000.00),
188 ContributionType::Regular,
189 Utc::now(),
190 Utc::now() + Duration::days(30),
191 None,
192 rust_decimal::Decimal::ZERO, )
194 .expect("appel de fonds valide"),
195 quote_part: OwnerContribution::new(
196 acp,
197 syndic,
198 Uuid::new_v4(),
199 Some(Uuid::new_v4()),
200 "Quote-part provision T1 2026".to_string(),
201 dec!(1200.00),
202 ContributionType::Regular,
203 Utc::now(),
204 Some("7000".to_string()),
205 )
206 .expect("quote-part valide"),
207 ecriture: {
208 let id = Uuid::new_v4();
209 JournalEntry::new(
210 acp,
211 syndic,
212 Some(immeuble),
213 Utc::now(),
214 Some("Entretien de la chaudière".to_string()),
215 None,
216 Some("ACH".to_string()),
217 None,
218 None,
219 vec![
220 JournalEntryLine::new_debit(
221 id,
222 syndic,
223 "610".to_string(),
224 dec!(1200.00),
225 None,
226 )
227 .expect("débit valide"),
228 JournalEntryLine::new_credit(
229 id,
230 syndic,
231 "440".to_string(),
232 dec!(1200.00),
233 None,
234 )
235 .expect("crédit valide"),
236 ],
237 None,
238 )
239 .expect("écriture valide")
240 },
241 assemblee: Meeting::new(
242 acp,
243 syndic,
244 immeuble,
245 MeetingType::Ordinary,
246 "AGO 2026".to_string(),
247 None,
248 Utc::now() + Duration::days(30),
249 "Salle communale".to_string(),
250 )
251 .expect("assemblée valide"),
252 convocation: Convocation::new(
253 acp,
254 syndic,
255 immeuble,
256 Uuid::new_v4(),
257 ConvocationType::Ordinary,
258 Utc::now() + Duration::days(30),
259 "FR".to_string(),
260 Uuid::new_v4(),
261 )
262 .expect("convocation valide"),
263 etat_date: EtatDate::new(
264 acp,
265 syndic,
266 immeuble,
267 Uuid::new_v4(),
268 Utc::now(),
269 EtatDateLanguage::Fr,
270 "Me Dupont".to_string(),
271 "dupont@notaire.be".to_string(),
272 None,
273 "Résidence du Parc".to_string(),
274 "12 Rue de la Loi".to_string(),
275 "A101".to_string(),
276 Some("1".to_string()),
277 Some(85.0),
278 dec!(100),
279 dec!(100),
280 )
281 .expect("état daté valide"),
282 relance: PaymentReminder::new(
283 acp,
284 syndic,
285 Uuid::new_v4(),
286 Uuid::new_v4(),
287 ReminderLevel::FirstReminder,
288 dec!(450.00),
289 Utc::now() - Duration::days(45),
290 45,
291 )
292 .expect("relance valide"),
293 }
294 }
295
296 fn pieces(&self) -> Vec<&dyn PieceDeGestion> {
297 vec![
298 &self.charge,
299 &self.budget,
300 &self.appel_de_fonds,
301 &self.quote_part,
302 &self.ecriture,
303 &self.assemblee,
304 &self.convocation,
305 &self.etat_date,
306 &self.relance,
307 ]
308 }
309 }
310
311 #[test]
321 fn negative_passe_trente_jours_sans_remise_le_syndic_sortant_est_en_defaut() {
322 let acp = Uuid::new_v4();
323 let sortant = Uuid::new_v4();
324 let entrant = Uuid::new_v4();
325 let fin = Utc::now() - Duration::days(40);
326
327 let passation = PassationDeDossier::nouvelle(acp, sortant, entrant, fin);
328
329 assert_eq!(
331 passation.echeance,
332 fin + Duration::days(30),
333 "le délai court depuis la fin du mandat"
334 );
335
336 assert_eq!(
338 passation.etat(fin + Duration::days(29)),
339 EtatPassation::EnCours
340 );
341 assert_eq!(passation.jours_restants(fin + Duration::days(29)), 1);
342
343 assert_eq!(
346 passation.etat(fin + Duration::days(31)),
347 EtatPassation::EnDefaut
348 );
349
350 let mut tardive = passation.clone();
352 tardive.remettre(fin + Duration::days(45));
353 assert_eq!(
354 tardive.etat(Utc::now()),
355 EtatPassation::RemisEnRetard,
356 "remettre après l'échéance ne régularise rien"
357 );
358
359 let mut a_temps = passation.clone();
361 a_temps.remettre(fin + Duration::days(10));
362 assert_eq!(a_temps.etat(Utc::now()), EtatPassation::RemisATemps);
363 }
364
365 #[test]
366 fn le_dossier_de_gestion_suit_lacp_lors_dune_passation() {
367 let acp = Uuid::new_v4();
368 let immeuble = Uuid::new_v4();
369 let cabinet_sortant = Uuid::new_v4();
370 let cabinet_entrant = Uuid::new_v4();
371
372 let passation = Utc::now();
373 let avant = passation - Duration::days(30);
374 let apres = passation + Duration::days(1);
375
376 let dossier = DossierComplet::pour(acp, cabinet_sortant, immeuble);
378 let pieces = dossier.pieces();
379
380 let mut mandat_sortant = SyndicMandate::new(acp, cabinet_sortant, avant, None);
381 mandat_sortant
382 .revoke(
383 passation,
384 None,
385 Some("Fin de mandat votée en AG".to_string()),
386 )
387 .expect("révocation valide");
388 let mandat_entrant = SyndicMandate::new(acp, cabinet_entrant, passation, None);
389 let mandats = vec![mandat_sortant, mandat_entrant];
390
391 assert_eq!(
393 perimetre_du_mandataire(&pieces, &mandats, cabinet_sortant, avant).len(),
394 pieces.len(),
395 "le mandataire en fonction doit voir tout le dossier"
396 );
397 assert!(
398 perimetre_du_mandataire(&pieces, &mandats, cabinet_entrant, avant).is_empty(),
399 "un cabinet sans mandat ne voit rien, même une pièce future"
400 );
401
402 assert_eq!(
404 perimetre_du_mandataire(&pieces, &mandats, cabinet_entrant, apres).len(),
405 pieces.len(),
406 "Art. 3.89 § 5, 7° : le successeur reçoit l'ensemble du dossier"
407 );
408 assert!(
409 perimetre_du_mandataire(&pieces, &mandats, cabinet_sortant, apres).is_empty(),
410 "le mandat éteint, le sortant n'a plus de base pour consulter le dossier"
411 );
412 }
413
414 #[test]
417 fn une_acp_sans_mandataire_conserve_son_dossier() {
418 let acp = Uuid::new_v4();
419 let ancien_syndic = Uuid::new_v4();
420 let passation = Utc::now();
421
422 let dossier = DossierComplet::pour(acp, ancien_syndic, Uuid::new_v4());
423 let pieces = dossier.pieces();
424
425 let mut mandat =
426 SyndicMandate::new(acp, ancien_syndic, passation - Duration::days(90), None);
427 mandat
428 .revoke(passation, None, None)
429 .expect("révocation valide");
430
431 assert!(
432 perimetre_du_mandataire(&pieces, &[mandat], ancien_syndic, passation).is_empty(),
433 "plus personne ne voit le dossier"
434 );
435 for piece in &pieces {
436 assert_eq!(
437 piece.acp_id(),
438 acp,
439 "mais chaque pièce sait encore à qui elle est"
440 );
441 }
442 }
443
444 #[test]
446 fn un_syndic_ne_voit_pas_le_dossier_dune_acp_quil_ne_gere_pas() {
447 let acp_geree = Uuid::new_v4();
448 let acp_voisine = Uuid::new_v4();
449 let cabinet = Uuid::new_v4();
450 let maintenant = Utc::now();
451
452 let dossier_voisin = DossierComplet::pour(acp_voisine, cabinet, Uuid::new_v4());
453 let pieces = dossier_voisin.pieces();
454
455 let mandat = SyndicMandate::new(acp_geree, cabinet, maintenant - Duration::days(10), None);
456
457 assert!(
458 perimetre_du_mandataire(&pieces, &[mandat], cabinet, maintenant).is_empty(),
459 "un mandat sur une ACP n'ouvre rien sur une autre, même chez le même cabinet"
460 );
461 }
462}