Skip to main content

koprogo_api/application/dto/
acp_dto.rs

1//! ACP DTOs — Story 1.1 (refonte UX multi-rôle ACP).
2//!
3//! Request/Response DTOs pour les endpoints `/acps`. Validation via `validator`.
4//! Bornes alignées avec les invariants `Acp::new` (cf. `domain/entities/acp.rs`).
5
6use serde::{Deserialize, Serialize};
7use validator::Validate;
8
9/// Création d'une ACP.
10///
11/// Champs :
12/// - `organization_id` : `Option<String>` — UUID du cabinet syndic, `None` si
13///   ACP auto-gérée (ADR-0010).
14/// - `name` : 2..=160 chars (post-trim côté domain).
15/// - `address_street`, `address_postal_code`, `address_city` : obligatoires.
16/// - `bce_number` : optionnel (toutes les ACPs ne sont pas immatriculées BCE).
17#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
18pub struct CreateAcpDto {
19    pub organization_id: Option<String>,
20
21    #[validate(length(
22        min = 2,
23        max = 160,
24        message = "Name must be between 2 and 160 characters"
25    ))]
26    pub name: String,
27
28    #[validate(length(min = 1, message = "address_street cannot be empty"))]
29    pub address_street: String,
30
31    #[validate(length(min = 1, message = "address_postal_code cannot be empty"))]
32    pub address_postal_code: String,
33
34    #[validate(length(min = 1, message = "address_city cannot be empty"))]
35    pub address_city: String,
36
37    #[validate(length(max = 20, message = "bce_number too long"))]
38    pub bce_number: Option<String>,
39
40    /// Dénominateur de l'acte de base (quotités) — défaut 1000 si absent.
41    /// 1000 millièmes / 10000 dix-millièmes / autre (Art. 3.84 CC, ADR-0010).
42    #[validate(range(min = 1, message = "total_tantiemes must be > 0"))]
43    pub total_tantiemes: Option<i32>,
44}
45
46/// Mise à jour d'une ACP (PATCH-like : tous les champs identitaires sont requis,
47/// par défaut on ré-envoie l'état complet via PUT — pattern Building).
48#[derive(Debug, Clone, Serialize, Deserialize, Validate, utoipa::ToSchema)]
49pub struct UpdateAcpDto {
50    /// Permet à un admin de rattacher / détacher l'ACP d'un cabinet.
51    /// `Some(None)` (JSON `"organization_id": null`) = détache,
52    /// `None` (clé absente) = conserve l'existant.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub organization_id: Option<Option<String>>,
55
56    #[validate(length(
57        min = 2,
58        max = 160,
59        message = "Name must be between 2 and 160 characters"
60    ))]
61    pub name: String,
62
63    #[validate(length(min = 1, message = "address_street cannot be empty"))]
64    pub address_street: String,
65
66    #[validate(length(min = 1, message = "address_postal_code cannot be empty"))]
67    pub address_postal_code: String,
68
69    #[validate(length(min = 1, message = "address_city cannot be empty"))]
70    pub address_city: String,
71
72    #[validate(length(max = 20, message = "bce_number too long"))]
73    pub bce_number: Option<String>,
74
75    /// Dénominateur de l'acte de base (quotités) — défaut 1000 si absent.
76    #[validate(range(min = 1, message = "total_tantiemes must be > 0"))]
77    pub total_tantiemes: Option<i32>,
78}
79
80/// Réponse JSON pour une ACP.
81///
82/// IDs et timestamps sérialisés en `String` (UUID + RFC3339) pour
83/// cohérence avec les autres DTOs (cf. `BuildingResponseDto`).
84#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
85pub struct AcpResponseDto {
86    pub id: String,
87    pub organization_id: Option<String>,
88    pub name: String,
89    pub slug: String,
90    pub legal_status: String,
91    pub total_tantiemes: i32,
92    pub bce_number: Option<String>,
93    pub address_street: String,
94    pub address_postal_code: String,
95    pub address_city: String,
96    pub created_at: String,
97    pub updated_at: String,
98}
99
100/// Une ACP **avec ses métriques**, pour la table « Mes ACP » du syndic.
101///
102/// ── Pourquoi un DTO distinct ────────────────────────────────────────────
103///
104/// `AcpResponseDto` est servi partout : sélecteurs, listes déroulantes, fiches.
105/// Y ajouter quatre agrégats obligerait tous ces appelants à payer quatre
106/// sous-requêtes par ligne pour des nombres qu'ils n'affichent pas.
107///
108/// ── Ce que ces nombres disent, et pourquoi ils vont ensemble ───────────
109///
110/// `units_count` et `declared_units_total` mesurent des choses différentes —
111/// les lots effectivement encodés, et ceux déclarés à l'acte de base. Les
112/// afficher séparément était déjà source de confusion ailleurs dans le
113/// produit (« 0 lots au total » à côté de « 8 Lots »). Ensemble, ils disent
114/// l'écart.
115///
116/// `quota_sum` face à `total_tantiemes` dit la même chose côté quotités : tant
117/// que la somme n'atteint pas le total de l'acte, les appels de fonds portent
118/// sur une base incomplète.
119#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
120pub struct AcpAvecMetriquesDto {
121    #[serde(flatten)]
122    pub acp: AcpResponseDto,
123    /// Nombre de blocs rattachés à l'ACP.
124    pub buildings_count: i32,
125    /// Lots effectivement encodés, tous blocs confondus.
126    pub units_count: i32,
127    /// Lots déclarés à l'acte de base, sommés sur les blocs.
128    pub declared_units_total: i32,
129    /// Somme exacte des quotités générales.
130    ///
131    /// `Decimal` sérialisé en chaîne : une quotité est juridiquement
132    /// opposable, et la passer en `f64` introduirait une erreur de
133    /// représentation sur une valeur exacte (ADR-0007/0008).
134    pub quota_sum: String,
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use validator::Validate;
141
142    #[test]
143    fn happy_valid_create_dto_passes_validation() {
144        let dto = CreateAcpDto {
145            organization_id: Some(uuid::Uuid::new_v4().to_string()),
146            name: "Acp Test".to_string(),
147            address_street: "Rue X 1".to_string(),
148            address_postal_code: "1000".to_string(),
149            address_city: "Bruxelles".to_string(),
150            bce_number: None,
151            total_tantiemes: None,
152        };
153        assert!(dto.validate().is_ok());
154    }
155
156    #[test]
157    fn negative_too_short_name_fails_validation() {
158        let dto = CreateAcpDto {
159            organization_id: None,
160            name: "A".to_string(),
161            address_street: "Rue X 1".to_string(),
162            address_postal_code: "1000".to_string(),
163            address_city: "Bruxelles".to_string(),
164            bce_number: None,
165            total_tantiemes: None,
166        };
167        assert!(dto.validate().is_err());
168    }
169
170    #[test]
171    fn negative_empty_street_fails_validation() {
172        let dto = CreateAcpDto {
173            organization_id: None,
174            name: "Acp Test".to_string(),
175            address_street: "".to_string(),
176            address_postal_code: "1000".to_string(),
177            address_city: "Bruxelles".to_string(),
178            bce_number: None,
179            total_tantiemes: None,
180        };
181        assert!(dto.validate().is_err());
182    }
183}