koprogo_api/domain/entities/
service_provider.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Service Provider (Contractor) — Marketplace
6/// Issue #276: Marketplace corps de métier
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub enum TradeCategory {
9    Syndic,
10    BureauEtude,
11    Architecte,
12    AssistantMaitreOeuvre,
13    IngenieurStabilite,
14    Plombier,
15    Electricien,
16    Chauffagiste,
17    Menuisier,
18    Peintre,
19    Maconnerie,
20    Etancheite,
21    Ascensoriste,
22    Jardinier,
23    Nettoyage,
24    Securite,
25    Deboucheur,
26    Couvreur,
27    Carreleur,
28    TechniquesSpeciales,
29}
30
31impl TradeCategory {
32    pub fn to_sql(&self) -> &'static str {
33        match self {
34            TradeCategory::Syndic => "Syndic",
35            TradeCategory::BureauEtude => "BureauEtude",
36            TradeCategory::Architecte => "Architecte",
37            TradeCategory::AssistantMaitreOeuvre => "AssistantMaitreOeuvre",
38            TradeCategory::IngenieurStabilite => "IngenieurStabilite",
39            TradeCategory::Plombier => "Plombier",
40            TradeCategory::Electricien => "Electricien",
41            TradeCategory::Chauffagiste => "Chauffagiste",
42            TradeCategory::Menuisier => "Menuisier",
43            TradeCategory::Peintre => "Peintre",
44            TradeCategory::Maconnerie => "Maconnerie",
45            TradeCategory::Etancheite => "Etancheite",
46            TradeCategory::Ascensoriste => "Ascensoriste",
47            TradeCategory::Jardinier => "Jardinier",
48            TradeCategory::Nettoyage => "Nettoyage",
49            TradeCategory::Securite => "Securite",
50            TradeCategory::Deboucheur => "Deboucheur",
51            TradeCategory::Couvreur => "Couvreur",
52            TradeCategory::Carreleur => "Carreleur",
53            TradeCategory::TechniquesSpeciales => "TechniquesSpeciales",
54        }
55    }
56
57    pub fn from_sql(s: &str) -> Result<Self, String> {
58        match s {
59            "Syndic" => Ok(TradeCategory::Syndic),
60            "BureauEtude" => Ok(TradeCategory::BureauEtude),
61            "Architecte" => Ok(TradeCategory::Architecte),
62            "AssistantMaitreOeuvre" => Ok(TradeCategory::AssistantMaitreOeuvre),
63            "IngenieurStabilite" => Ok(TradeCategory::IngenieurStabilite),
64            "Plombier" => Ok(TradeCategory::Plombier),
65            "Electricien" => Ok(TradeCategory::Electricien),
66            "Chauffagiste" => Ok(TradeCategory::Chauffagiste),
67            "Menuisier" => Ok(TradeCategory::Menuisier),
68            "Peintre" => Ok(TradeCategory::Peintre),
69            "Maconnerie" => Ok(TradeCategory::Maconnerie),
70            "Etancheite" => Ok(TradeCategory::Etancheite),
71            "Ascensoriste" => Ok(TradeCategory::Ascensoriste),
72            "Jardinier" => Ok(TradeCategory::Jardinier),
73            "Nettoyage" => Ok(TradeCategory::Nettoyage),
74            "Securite" => Ok(TradeCategory::Securite),
75            "Deboucheur" => Ok(TradeCategory::Deboucheur),
76            "Couvreur" => Ok(TradeCategory::Couvreur),
77            "Carreleur" => Ok(TradeCategory::Carreleur),
78            "TechniquesSpeciales" => Ok(TradeCategory::TechniquesSpeciales),
79            _ => Err(format!("Invalid trade category: {}", s)),
80        }
81    }
82}
83
84#[derive(Clone)]
85pub struct ServiceProvider {
86    pub id: Uuid,
87    pub organization_id: Uuid,
88    pub company_name: String,
89    pub trade_category: TradeCategory,
90    pub specializations: Vec<String>,
91    pub service_zone_postal_codes: Vec<String>,
92    pub certifications: Vec<String>, // VCA, Saber, BOSEC, etc.
93    pub ipi_registration: Option<String>,
94    pub bce_number: Option<String>, // Belgian company number (BCE/KBO)
95    pub rating_avg: Option<f64>,    // 0.0-5.0
96    pub reviews_count: i32,
97    pub is_verified: bool,
98    pub public_profile_slug: String,
99    pub created_at: DateTime<Utc>,
100    pub updated_at: DateTime<Utc>,
101}
102
103impl ServiceProvider {
104    pub fn new(
105        organization_id: Uuid,
106        company_name: String,
107        trade_category: TradeCategory,
108        bce_number: Option<String>,
109    ) -> Result<Self, String> {
110        if company_name.is_empty() {
111            return Err("company_name cannot be empty".to_string());
112        }
113        let slug = generate_slug(&company_name);
114        Ok(Self {
115            id: Uuid::new_v4(),
116            organization_id,
117            company_name,
118            trade_category,
119            specializations: vec![],
120            service_zone_postal_codes: vec![],
121            certifications: vec![],
122            ipi_registration: None,
123            bce_number,
124            rating_avg: None,
125            reviews_count: 0,
126            is_verified: false,
127            public_profile_slug: slug,
128            created_at: Utc::now(),
129            updated_at: Utc::now(),
130        })
131    }
132
133    /// Update rating based on new evaluation
134    pub fn update_rating(&mut self, new_score: f64) -> Result<(), String> {
135        if new_score < 0.0 || new_score > 5.0 {
136            return Err("Rating must be between 0.0 and 5.0".to_string());
137        }
138        let current_avg = self.rating_avg.unwrap_or(0.0);
139        let new_reviews_count = self.reviews_count + 1;
140        self.rating_avg =
141            Some((current_avg * self.reviews_count as f64 + new_score) / new_reviews_count as f64);
142        self.reviews_count = new_reviews_count;
143        self.updated_at = Utc::now();
144        Ok(())
145    }
146}
147
148fn generate_slug(name: &str) -> String {
149    name.to_lowercase()
150        .chars()
151        .map(|c| if c.is_alphanumeric() { c } else { '-' })
152        .collect::<String>()
153        .split('-')
154        .filter(|s| !s.is_empty())
155        .collect::<Vec<_>>()
156        .join("-")
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_service_provider_new_success() {
165        let org_id = Uuid::new_v4();
166        let provider = ServiceProvider::new(
167            org_id,
168            "ABC Plomberie".to_string(),
169            TradeCategory::Plombier,
170            Some("BE123456789".to_string()),
171        );
172        assert!(provider.is_ok());
173        let p = provider.unwrap();
174        assert_eq!(p.company_name, "ABC Plomberie");
175        assert_eq!(p.public_profile_slug, "abc-plomberie");
176        assert_eq!(p.reviews_count, 0);
177        assert_eq!(p.rating_avg, None);
178    }
179
180    #[test]
181    fn test_service_provider_empty_name() {
182        let org_id = Uuid::new_v4();
183        let result = ServiceProvider::new(org_id, "".to_string(), TradeCategory::Plombier, None);
184        assert!(result.is_err());
185    }
186
187    #[test]
188    fn test_update_rating() {
189        let org_id = Uuid::new_v4();
190        let mut provider = ServiceProvider::new(
191            org_id,
192            "Test Provider".to_string(),
193            TradeCategory::Electricien,
194            None,
195        )
196        .unwrap();
197
198        let _ = provider.update_rating(4.0);
199        assert_eq!(provider.reviews_count, 1);
200        assert_eq!(provider.rating_avg, Some(4.0));
201
202        let _ = provider.update_rating(5.0);
203        assert_eq!(provider.reviews_count, 2);
204        assert_eq!(provider.rating_avg, Some(4.5));
205    }
206
207    #[test]
208    fn test_update_rating_invalid() {
209        let org_id = Uuid::new_v4();
210        let mut provider = ServiceProvider::new(
211            org_id,
212            "Test Provider".to_string(),
213            TradeCategory::Electricien,
214            None,
215        )
216        .unwrap();
217
218        let result = provider.update_rating(6.0);
219        assert!(result.is_err());
220    }
221}