package dev.lions.unionflow.server.service; import dev.lions.unionflow.server.api.dto.membre.request.CreateMembreRequest; import dev.lions.unionflow.server.api.dto.membre.request.UpdateMembreRequest; import dev.lions.unionflow.server.api.dto.membre.response.MembreResponse; import dev.lions.unionflow.server.api.dto.membre.response.MembreSummaryResponse; import dev.lions.unionflow.server.api.dto.membre.MembreSearchCriteria; import dev.lions.unionflow.server.api.dto.membre.MembreSearchResultDTO; import dev.lions.unionflow.server.entity.FormuleAbonnement; import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.repository.MembreRepository; import io.quarkus.panache.common.Page; import io.quarkus.panache.common.Sort; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; import jakarta.transaction.Transactional; import java.io.InputStream; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.Period; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import org.jboss.logging.Logger; /** Service métier pour les membres */ @ApplicationScoped public class MembreService { private static final Logger LOG = Logger.getLogger(MembreService.class); @Inject MembreRepository membreRepository; @Inject dev.lions.unionflow.server.repository.MembreRoleRepository membreRoleRepository; @Inject dev.lions.unionflow.server.repository.RoleRepository roleRepository; @Inject dev.lions.unionflow.server.repository.MembreOrganisationRepository membreOrganisationRepository; @Inject dev.lions.unionflow.server.repository.TypeReferenceRepository typeReferenceRepository; @Inject MembreImportExportService membreImportExportService; @PersistenceContext EntityManager entityManager; @Inject dev.lions.unionflow.server.service.OrganisationService organisationService; @Inject io.quarkus.security.identity.SecurityIdentity securityIdentity; @Inject dev.lions.unionflow.server.repository.InscriptionEvenementRepository inscriptionEvenementRepository; @Inject dev.lions.unionflow.server.messaging.KafkaEventProducer kafkaEventProducer; @Inject MembreKeycloakSyncService keycloakSyncService; @Inject AuditService auditService; @Inject dev.lions.unionflow.server.repository.NotificationRepository notificationRepository; /** Crée un nouveau membre en attente de validation admin */ @Transactional public Membre creerMembre(Membre membre) { LOG.infof("Création d'un nouveau membre: %s", membre.getEmail()); // Générer un numéro de membre unique if (membre.getNumeroMembre() == null || membre.getNumeroMembre().isEmpty()) { membre.setNumeroMembre(genererNumeroMembre()); } // Définir la date de naissance par défaut si non fournie (pour éviter @NotNull) if (membre.getDateNaissance() == null) { membre.setDateNaissance(LocalDate.now().minusYears(18)); LOG.warn("Date de naissance non fournie, définie par défaut à il y a 18 ans"); } // Vérifier l'unicité de l'email if (membreRepository.findByEmail(membre.getEmail()).isPresent()) { throw new IllegalArgumentException("Un membre avec cet email existe déjà"); } // Vérifier l'unicité du numéro de membre if (membreRepository.findByNumeroMembre(membre.getNumeroMembre()).isPresent()) { throw new IllegalArgumentException("Un membre avec ce numéro existe déjà"); } // Statut initial : en attente de validation admin // L'activation (ACTIF + Keycloak MEMBRE_ACTIF) se fait via PUT /api/membres/{id}/activer membre.setStatutCompte("EN_ATTENTE_VALIDATION"); membre.setActif(false); membreRepository.persist(membre); LOG.infof("Membre créé en attente de validation: %s (ID: %s)", membre.getNomComplet(), membre.getId()); // Publier l'événement Kafka pour mise à jour temps réel try { Map memberData = new HashMap<>(); memberData.put("memberId", membre.getId().toString()); memberData.put("nomComplet", membre.getNomComplet()); memberData.put("email", membre.getEmail()); memberData.put("numeroMembre", membre.getNumeroMembre()); memberData.put("statutCompte", membre.getStatutCompte()); kafkaEventProducer.publishMemberCreated(membre.getId(), null, memberData); } catch (Exception e) { LOG.warnf("Kafka event publication failed (non-blocking): %s", e.getMessage()); } return membre; } /** * Active un membre : passe son statut à ACTIF et son flag actif à true. * Doit être suivi d'un appel à MembreKeycloakSyncService.activerMembreDansKeycloak() * pour que le rôle MEMBRE_ACTIF soit assigné dans Keycloak. * * @param membreId UUID du membre à activer * @return Le membre mis à jour * @throws jakarta.ws.rs.NotFoundException si le membre est introuvable */ @Transactional public Membre activerMembre(UUID membreId) { LOG.infof("Activation du membre ID: %s", membreId); Membre membre = membreRepository.findByIdOptional(membreId) .orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre non trouvé avec l'ID: " + membreId)); membre.setStatutCompte("ACTIF"); membre.setActif(true); membreRepository.persist(membre); LOG.infof("Membre activé avec succès: %s (ID: %s)", membre.getNomComplet(), membreId); try { Map memberData = new HashMap<>(); memberData.put("memberId", membre.getId().toString()); memberData.put("nomComplet", membre.getNomComplet()); memberData.put("statutCompte", "ACTIF"); kafkaEventProducer.publishMemberUpdated(membre.getId(), null, memberData); } catch (Exception e) { LOG.warnf("Kafka event publication failed (non-blocking): %s", e.getMessage()); } return membre; } /** * Affecte un membre existant à une organisation. * Crée le lien MembreOrganisation (statut EN_ATTENTE_VALIDATION) si inexistant. * Si le lien existe déjà, la méthode est idempotente. * * @param membreId UUID du membre * @param organisationId UUID de l'organisation cible * @return Le membre mis à jour */ @Transactional public Membre affecterOrganisation(UUID membreId, UUID organisationId) { LOG.infof("Affectation du membre %s à l'organisation %s", membreId, organisationId); Membre membre = membreRepository.findByIdOptional(membreId) .orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre non trouvé: " + membreId)); boolean dejaLie = membreOrganisationRepository.findFirstByMembreId(membreId).isPresent(); if (dejaLie) { LOG.infof("Membre %s déjà lié à une organisation — opération ignorée", membreId); return membre; } lierMembreOrganisationEtIncrementerQuota(membre, organisationId, "EN_ATTENTE_VALIDATION"); LOG.infof("Membre %s affecté à l'organisation %s", membre.getNumeroMembre(), organisationId); return membre; } /** * Promeut un membre au rôle d'administrateur d'organisation. * Passe immédiatement le statut à ACTIF — les admins sont opérationnels sans * validation intermédiaire. * Doit être suivi d'un appel à * MembreKeycloakSyncService.promouvoirAdminOrganisationDansKeycloak() * pour que le rôle ADMIN_ORGANISATION soit assigné dans Keycloak. * * @param membreId UUID du membre à promouvoir * @return Le membre mis à jour * @throws jakarta.ws.rs.NotFoundException si le membre est introuvable */ @Transactional public Membre promouvoirAdminOrganisation(UUID membreId) { LOG.infof("Promotion admin d'organisation pour le membre ID: %s", membreId); Membre membre = membreRepository.findByIdOptional(membreId) .orElseThrow(() -> new jakarta.ws.rs.NotFoundException("Membre non trouvé avec l'ID: " + membreId)); // Vérifier le quota d'administrateurs selon la formule souscrite membreOrganisationRepository.findFirstByMembreId(membreId).ifPresent(mo -> { UUID orgId = mo.getOrganisation().getId(); entityManager.createQuery( "SELECT s FROM SouscriptionOrganisation s WHERE s.organisation.id = :orgId AND s.statut = 'ACTIVE'", dev.lions.unionflow.server.entity.SouscriptionOrganisation.class) .setParameter("orgId", orgId) .getResultStream().findFirst().ifPresent(souscription -> { FormuleAbonnement formule = souscription.getFormule(); if (formule != null && formule.getMaxAdmins() != null) { long adminCount = entityManager.createQuery( "SELECT COUNT(mr) FROM MembreRole mr WHERE mr.organisation.id = :orgId " + "AND mr.role.code = 'ORGADMIN' AND mr.actif = true", Long.class) .setParameter("orgId", orgId).getSingleResult(); if (adminCount >= formule.getMaxAdmins()) { throw new jakarta.ws.rs.ForbiddenException( "Le quota d'administrateurs de votre plan (" + formule.getMaxAdmins() + ") est atteint. Mettez à niveau votre abonnement pour ajouter plus d'administrateurs."); } } }); }); membre.setStatutCompte("ACTIF"); membre.setActif(true); membreRepository.persist(membre); // Mettre à jour le rôle BDD vers ORGADMIN membreOrganisationRepository.findFirstByMembreId(membreId).ifPresent(mo -> { membreRoleRepository.findActifsByMembreId(membreId) .forEach(mr -> { mr.setActif(false); entityManager.persist(mr); }); assignerRoleDefaut(mo, "ORGADMIN"); }); LOG.infof("Membre promu admin d'organisation: %s (ID: %s)", membre.getNomComplet(), membreId); return membre; } /** Met à jour un membre existant */ @Transactional public Membre mettreAJourMembre(UUID id, Membre membreModifie) { LOG.infof("Mise à jour du membre ID: %s", id); Membre membre = membreRepository.findById(id); if (membre == null) { throw new IllegalArgumentException("Membre non trouvé avec l'ID: " + id); } // Vérifier l'unicité de l'email si modifié if (!membre.getEmail().equals(membreModifie.getEmail())) { if (membreRepository.findByEmail(membreModifie.getEmail()).isPresent()) { throw new IllegalArgumentException("Un membre avec cet email existe déjà"); } } // Mettre à jour les champs membre.setPrenom(membreModifie.getPrenom()); membre.setNom(membreModifie.getNom()); membre.setEmail(membreModifie.getEmail()); membre.setTelephone(membreModifie.getTelephone()); membre.setDateNaissance(membreModifie.getDateNaissance()); membre.setActif(membreModifie.getActif()); LOG.infof("Membre mis à jour avec succès: %s", membre.getNomComplet()); return membre; } /** Trouve un membre par son ID */ public Optional trouverParId(UUID id) { return Optional.ofNullable(membreRepository.findById(id)); } /** Trouve un membre par son email */ public Optional trouverParEmail(String email) { return membreRepository.findByEmail(email); } /** Trouve un membre par son numéro de membre (ex: MBR-0001) */ public Optional trouverParNumeroMembre(String numeroMembre) { return membreRepository.findByNumeroMembre(numeroMembre); } /** Liste tous les membres actifs */ public List listerMembresActifs() { return membreRepository.findAllActifs(); } /** Recherche des membres par nom ou prénom */ public List rechercherMembres(String recherche) { return membreRepository.findByNomOrPrenom(recherche); } /** * Désactive un membre avec propagation complète des cascades métier. * *

Garde-fous et effets : *

    *
  1. Check mono-admin : si le membre est le seul ORGADMIN d'une org, * lève {@link jakarta.ws.rs.WebApplicationException} 409 Conflict — l'appelant * doit d'abord assigner un autre admin pour éviter l'orphelinage.
  2. *
  3. DB : {@code actif=false}, {@code statutCompte='DESACTIVE'}
  4. *
  5. Toutes les adhésions actives → {@code SUSPENDU}, {@code nombreMembres} décrémenté
  6. *
  7. Tous les {@link dev.lions.unionflow.server.entity.MembreRole} → {@code actif=false} * (perte immédiate des droits fonctionnels)
  8. *
  9. Keycloak (lions-user-manager) : {@code user.enabled=false} → login impossible
  10. *
  11. Kafka : événement {@code member.deactivated} émis pour les consommateurs externes
  12. *
* *

Non couvert (laissé à des services spécialisés) : comptes épargne, cotisations, * inscriptions événements, approbations en attente — à traiter via workflow dédié. */ @Transactional public void desactiverMembre(UUID id) { LOG.infof("Désactivation du membre ID: %s", id); Membre membre = membreRepository.findById(id); if (membre == null) { throw new IllegalArgumentException("Membre non trouvé avec l'ID: " + id); } // ── 1. GARDE-FOU mono-admin : refuser si l'orphelinage créerait une org sans admin ── List orgsOrphelines = verifierOrgsOrphelinees(id); if (!orgsOrphelines.isEmpty()) { final String msg = "Suppression impossible : ce membre est le seul administrateur de " + orgsOrphelines.size() + " organisation(s) (" + String.join(", ", orgsOrphelines) + "). Veuillez d'abord désigner un autre administrateur avant de supprimer ce compte."; LOG.warnf("Refus désactivation %s (mono-admin de %s)", id, orgsOrphelines); throw new jakarta.ws.rs.WebApplicationException(msg, jakarta.ws.rs.core.Response.Status.CONFLICT); } // ── 2. DB : flags principaux du membre ─────────────────────────────────────────── membre.setActif(false); membre.setStatutCompte("DESACTIVE"); // ── 3. Adhésions actives → SUSPENDU + décrément compteur org ───────────────────── final var adhesionsActives = membreOrganisationRepository.findOrganisationsActivesParMembre(id); for (var mo : adhesionsActives) { mo.getOrganisation().retirerMembre(); mo.setStatutMembre(dev.lions.unionflow.server.api.enums.membre.StatutMembre.SUSPENDU); } final int nbAdhesionsSuspendues = adhesionsActives.size(); // ── 4. Désactivation des rôles fonctionnels (ORGADMIN, TRESORIER, etc.) ───────── final int rolesDesactives = (int) membreRoleRepository.update( "actif = false, dateFin = ?1, modifiePar = ?2 " + "WHERE membreOrganisation.membre.id = ?3 AND actif = true", LocalDate.now(), "system", id); LOG.infof("%d MembreRole désactivés pour le membre %s", rolesDesactives, id); // ── 5. Annulation des notifications pending pour ce membre ────────────────────── try { final long notifsAnnulees = notificationRepository.update( "statut = ?1, dateModification = ?2 " + "WHERE membre.id = ?3 AND statut IN (?4, ?5) AND actif = true", "ANNULEE", java.time.LocalDateTime.now(), id, "EN_ATTENTE", "ECHEC_TEMPORAIRE"); if (notifsAnnulees > 0) { LOG.infof("%d notifications pending annulées pour membre %s", notifsAnnulees, id); } } catch (Exception e) { LOG.warnf("Annulation notifications pending échouée pour %s : %s", id, e.getMessage()); } // ── 6. Propagation Keycloak (non bloquant) ─────────────────────────────────────── try { keycloakSyncService.syncMembreToKeycloak(id); LOG.infof("Compte Keycloak désactivé pour membre %s", id); } catch (Exception e) { LOG.warnf("Sync Keycloak échouée pour membre %s : %s (DB reste cohérente)", id, e.getMessage()); } // ── 7. Événement Kafka pour les autres modules/services ───────────────────────── try { kafkaEventProducer.publishMemberDeactivated(membre); } catch (Exception e) { LOG.warnf("Publication Kafka member.deactivated échouée pour %s : %s", id, e.getMessage()); } // ── 8. Audit log (traçabilité RGPD/compliance) ────────────────────────────────── try { String operateur = securityIdentity != null && !securityIdentity.isAnonymous() ? securityIdentity.getPrincipal().getName() : "system"; auditService.logMembreDesactive(id, membre.getEmail(), operateur, nbAdhesionsSuspendues, rolesDesactives); } catch (Exception e) { LOG.warnf("Audit log MEMBRE_DESACTIVE échoué pour %s : %s", id, e.getMessage()); } LOG.infof("Membre désactivé avec cascade complète : %s (adhésions=%d, rôles=%d)", membre.getNomComplet(), nbAdhesionsSuspendues, rolesDesactives); } /** * Vérifie si la désactivation d'un membre entraînerait l'orphelinage d'organisations * (i.e. le membre est le seul ORGADMIN actif d'au moins une org). * * @return liste des noms d'organisations qui deviendraient orphelines (vide si OK) */ private List verifierOrgsOrphelinees(UUID membreId) { List orphelines = new ArrayList<>(); // Toutes les orgs où ce membre est ORGADMIN actif final LocalDate today = LocalDate.now(); List rolesAdmin = membreRoleRepository.list( "membreOrganisation.membre.id = ?1 AND role.code = ?2 AND actif = true " + "AND (dateDebut IS NULL OR dateDebut <= ?3) " + "AND (dateFin IS NULL OR dateFin >= ?3)", membreId, "ORGADMIN", today); for (var role : rolesAdmin) { if (role.getOrganisation() == null) continue; UUID orgId = role.getOrganisation().getId(); long totalAdmins = membreRoleRepository.countAdminsByOrganisationId(orgId); // Si ce membre est le seul admin (total=1) et qu'on le désactive → org orpheline if (totalAdmins <= 1) { orphelines.add(role.getOrganisation().getNom()); } } return orphelines; } /** Génère un numéro de membre unique */ private String genererNumeroMembre() { String prefix = "UF" + LocalDate.now().getYear(); String suffix = UUID.randomUUID().toString().substring(0, 8).toUpperCase(); return prefix + "-" + suffix; } /** Compte le nombre total de membres actifs */ public long compterMembresActifs() { return membreRepository.countActifs(); } /** Liste tous les membres actifs avec pagination */ public List listerMembresActifs(Page page, Sort sort) { return membreRepository.findAllActifs(page, sort); } /** Liste tous les membres avec pagination. Pour ADMIN_ORGANISATION, limite aux membres de ses organisations. */ public List listerMembres(Page page, Sort sort) { Optional> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg(); if (orgIds.isPresent()) { Set ids = orgIds.get(); if (ids.isEmpty()) return List.of(); return membreRepository.findDistinctByOrganisationIdIn(ids, page, sort); } // SuperAdmin : filtre les désactivés par défaut (ne pas polluer les listes UI) return membreRepository.findAllActifs(page, sort); } /** Compte les membres actifs. Pour ADMIN_ORGANISATION, compte uniquement les membres de ses organisations. */ public long compterMembres() { Optional> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg(); if (orgIds.isPresent()) { Set ids = orgIds.get(); if (ids.isEmpty()) return 0L; return membreRepository.countDistinctByOrganisationIdIn(ids); } return membreRepository.countActifs(); } /** Recherche des membres avec pagination. Pour ADMIN_ORGANISATION, limite aux membres de ses organisations. */ public List rechercherMembres(String recherche, Page page, Sort sort) { Optional> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg(); if (orgIds.isPresent()) { Set ids = orgIds.get(); if (ids.isEmpty()) return List.of(); return membreRepository.findByNomOrPrenomAndOrganisationIdIn(recherche, ids, page, sort); } return membreRepository.findByNomOrPrenom(recherche, page, sort); } /** * Si l'utilisateur connecté est ADMIN_ORGANISATION (et pas ADMIN/SUPER_ADMIN), retourne les IDs de ses organisations. * Sinon retourne Optional.empty() pour indiquer "tous les membres". */ private Optional> getOrganisationIdsForCurrentUserIfAdminOrg() { if (securityIdentity.getPrincipal() == null) return Optional.empty(); Set roles = securityIdentity.getRoles(); if (roles == null) return Optional.empty(); boolean adminOrg = roles.contains("ADMIN_ORGANISATION"); boolean adminOrSuper = roles.contains("ADMIN") || roles.contains("SUPER_ADMIN"); if (!adminOrg || adminOrSuper) return Optional.empty(); String email = securityIdentity.getPrincipal().getName(); if (email == null || email.isBlank()) return Optional.empty(); List orgs = organisationService.listerOrganisationsPourUtilisateur(email); if (orgs == null || orgs.isEmpty()) return Optional.of(Set.of()); Set ids = orgs.stream().map(dev.lions.unionflow.server.entity.Organisation::getId).collect(Collectors.toCollection(LinkedHashSet::new)); return Optional.of(ids); } /** Obtient les statistiques avancées des membres */ public Map obtenirStatistiquesAvancees() { LOG.info("Calcul des statistiques avancées des membres"); long totalMembres = membreRepository.count(); long membresActifs = membreRepository.countActifs(); long membresInactifs = totalMembres - membresActifs; long nouveauxMembres30Jours = membreRepository.countNouveauxMembres(LocalDate.now().minusDays(30)); long totalOrganisations = organisationService.rechercherOrganisationsCount(""); Map stats = new java.util.HashMap<>(); stats.put("totalMembres", totalMembres); stats.put("total", totalMembres); // alias pour compatibilité mobile stats.put("membresActifs", membresActifs); stats.put("membresInactifs", membresInactifs); stats.put("nouveauxMembres30Jours", nouveauxMembres30Jours); stats.put("tauxActivite", totalMembres > 0 ? (membresActifs * 100.0 / totalMembres) : 0.0); stats.put("totalOrganisations", totalOrganisations); stats.put("timestamp", LocalDateTime.now()); return stats; } // ======================================== // MÉTHODES DE CONVERSION DTO // ======================================== /** Convertit une entité Membre en MembreResponse */ public MembreResponse convertToResponse(Membre membre) { if (membre == null) { return null; } MembreResponse dto = new MembreResponse(); dto.setId(membre.getId()); dto.setNumeroMembre(membre.getNumeroMembre()); dto.setKeycloakId(membre.getKeycloakId()); dto.setPrenom(membre.getPrenom()); dto.setNom(membre.getNom()); dto.setNomComplet(membre.getNomComplet()); dto.setEmail(membre.getEmail()); dto.setTelephone(membre.getTelephone()); dto.setTelephoneWave(membre.getTelephoneWave()); dto.setDateNaissance(membre.getDateNaissance()); dto.setAge(membre.getAge()); dto.setProfession(membre.getProfession()); dto.setPhotoUrl(membre.getPhotoUrl()); dto.setStatutMatrimonial(membre.getStatutMatrimonial()); if (membre.getStatutMatrimonial() != null) { dto.setStatutMatrimonialLibelle( typeReferenceRepository.findLibelleByDomaineAndCode("STATUT_MATRIMONIAL", membre.getStatutMatrimonial())); } dto.setNationalite(membre.getNationalite()); dto.setTypeIdentite(membre.getTypeIdentite()); if (membre.getTypeIdentite() != null) { dto.setTypeIdentiteLibelle( typeReferenceRepository.findLibelleByDomaineAndCode("TYPE_IDENTITE", membre.getTypeIdentite())); } dto.setNumeroIdentite(membre.getNumeroIdentite()); dto.setNiveauVigilanceKyc(membre.getNiveauVigilanceKyc()); dto.setStatutKyc(membre.getStatutKyc()); dto.setDateVerificationIdentite(membre.getDateVerificationIdentite()); dto.setStatutCompte(membre.getStatutCompte()); if (membre.getStatutCompte() != null) { dto.setStatutCompteLibelle( typeReferenceRepository.findLibelleByDomaineAndCode("STATUT_COMPTE", membre.getStatutCompte())); dto.setStatutCompteSeverity( typeReferenceRepository.findSeverityByDomaineAndCode("STATUT_COMPTE", membre.getStatutCompte())); } // Chargement de tous les rôles actifs via MembreOrganisation → MembreRole List roles = membreRoleRepository .findActifsByMembreId(membre.getId()); if (!roles.isEmpty()) { List roleCodes = roles.stream() .filter(r -> r.getRole() != null) .map(r -> r.getRole().getCode()) .collect(Collectors.toList()); dto.setRoles(roleCodes); } else { dto.setRoles(new ArrayList<>()); } if (membre.getMembresOrganisations() != null && !membre.getMembresOrganisations().isEmpty()) { dev.lions.unionflow.server.entity.MembreOrganisation mo = membre.getMembresOrganisations().get(0); if (mo.getOrganisation() != null) { dto.setOrganisationId(mo.getOrganisation().getId()); dto.setOrganisationNom(mo.getOrganisation().getNom()); } dto.setDateAdhesion(mo.getDateAdhesion()); } else if (membre.getDateCreation() != null) { // Fallback : date de création du compte comme date d'adhésion (membres sans organisation) dto.setDateAdhesion(membre.getDateCreation().toLocalDate()); } // Nombre d'événements auxquels le membre a participé dto.setNombreEvenementsParticipes( (int) inscriptionEvenementRepository.countByMembre(membre.getId())); // Adresse principale (principale=true en priorité, sinon première adresse active) if (membre.getAdresses() != null && !membre.getAdresses().isEmpty()) { dev.lions.unionflow.server.entity.Adresse adressePrincipale = membre.getAdresses().stream() .filter(a -> Boolean.TRUE.equals(a.getPrincipale()) && Boolean.TRUE.equals(a.getActif())) .findFirst() .orElseGet(() -> membre.getAdresses().stream() .filter(a -> Boolean.TRUE.equals(a.getActif())) .findFirst() .orElse(null)); if (adressePrincipale != null) { dto.setAdresse(adressePrincipale.getAdresse()); dto.setVille(adressePrincipale.getVille()); dto.setCodePostal(adressePrincipale.getCodePostal()); } } // Notes / biographie dto.setNotes(membre.getNotes()); // Champs de base DTO dto.setDateCreation(membre.getDateCreation()); dto.setDateModification(membre.getDateModification()); dto.setCreePar(membre.getCreePar()); dto.setModifiePar(membre.getModifiePar()); dto.setActif(membre.getActif()); dto.setVersion(membre.getVersion() != null ? membre.getVersion() : 0L); return dto; } /** Convertit une entité Membre en MembreSummaryResponse */ public MembreSummaryResponse convertToSummaryResponse(Membre membre) { if (membre == null) { return null; } List rolesNames = new ArrayList<>(); List roles = membreRoleRepository .findActifsByMembreId(membre.getId()); if (!roles.isEmpty()) { rolesNames = roles.stream() .filter(r -> r.getRole() != null) .map(r -> r.getRole().getCode()) .collect(Collectors.toList()); } String libelle = null; String severity = null; if (membre.getStatutCompte() != null) { libelle = typeReferenceRepository.findLibelleByDomaineAndCode("STATUT_COMPTE", membre.getStatutCompte()); severity = typeReferenceRepository.findSeverityByDomaineAndCode("STATUT_COMPTE", membre.getStatutCompte()); } UUID organisationId = null; String organisationNom = null; java.time.LocalDate dateAdhesion = null; if (membre.getMembresOrganisations() != null && !membre.getMembresOrganisations().isEmpty()) { dev.lions.unionflow.server.entity.MembreOrganisation mo = membre.getMembresOrganisations().get(0); if (mo.getOrganisation() != null) { organisationId = mo.getOrganisation().getId(); organisationNom = mo.getOrganisation().getNom(); } dateAdhesion = mo.getDateAdhesion(); } return new MembreSummaryResponse( membre.getId(), membre.getNumeroMembre(), membre.getPrenom(), membre.getNom(), membre.getEmail(), membre.getTelephone(), membre.getProfession(), membre.getStatutCompte(), libelle, severity, membre.getActif(), rolesNames, organisationId, organisationNom, dateAdhesion); } /** Convertit un CreateMembreRequest en entité Membre */ public Membre convertFromCreateRequest(CreateMembreRequest dto) { if (dto == null) { return null; } Membre membre = new Membre(); // Copie des champs membre.setNom(dto.nom()); membre.setPrenom(dto.prenom()); membre.setEmail(dto.email()); membre.setTelephone(dto.telephone()); membre.setTelephoneWave(dto.telephoneWave()); membre.setDateNaissance(dto.dateNaissance()); membre.setProfession(dto.profession()); membre.setPhotoUrl(dto.photoUrl()); membre.setStatutMatrimonial(dto.statutMatrimonial()); membre.setNationalite(dto.nationalite()); membre.setTypeIdentite(dto.typeIdentite()); membre.setNumeroIdentite(dto.numeroIdentite()); return membre; } /** Convertit une liste d'entités en liste de MembreSummaryResponse */ public List convertToSummaryResponseList(List membres) { if (membres == null) return new ArrayList<>(); return membres.stream().map(this::convertToSummaryResponse).collect(Collectors.toList()); } /** Convertit une liste d'entités en liste de MembreResponse */ public List convertToResponseList(List membres) { if (membres == null) return new ArrayList<>(); return membres.stream().map(this::convertToResponse).collect(Collectors.toList()); } /** Met à jour une entité Membre à partir d'un UpdateMembreRequest */ public void updateFromRequest(Membre membre, UpdateMembreRequest dto) { if (membre == null || dto == null) { return; } // Mise à jour des champs modifiables membre.setPrenom(dto.prenom()); membre.setNom(dto.nom()); membre.setEmail(dto.email()); membre.setTelephone(dto.telephone()); membre.setTelephoneWave(dto.telephoneWave()); membre.setDateNaissance(dto.dateNaissance()); membre.setProfession(dto.profession()); membre.setPhotoUrl(dto.photoUrl()); membre.setStatutMatrimonial(dto.statutMatrimonial()); membre.setNationalite(dto.nationalite()); membre.setTypeIdentite(dto.typeIdentite()); membre.setNumeroIdentite(dto.numeroIdentite()); if (dto.actif() != null) { membre.setActif(dto.actif()); } membre.setDateModification(LocalDateTime.now()); } /** Recherche avancée de membres avec filtres multiples (DEPRECATED) */ public List rechercheAvancee( String recherche, Boolean actif, LocalDate dateAdhesionMin, LocalDate dateAdhesionMax, Page page, Sort sort) { LOG.infof( "Recherche avancée (DEPRECATED) - recherche: %s, actif: %s, dateMin: %s, dateMax: %s", recherche, actif, dateAdhesionMin, dateAdhesionMax); return membreRepository.rechercheAvancee( recherche, actif, dateAdhesionMin, dateAdhesionMax, page, sort); } /** * Nouvelle recherche avancée de membres avec critères complets Retourne des * résultats paginés * avec statistiques * * @param criteria Critères de recherche * @param page Pagination * @param sort Tri * @return Résultats de recherche avec métadonnées */ public MembreSearchResultDTO searchMembresAdvanced( MembreSearchCriteria criteria, Page page, Sort sort) { LOG.infof("Recherche avancée de membres - critères: %s", criteria.getDescription()); // Pour ADMIN_ORGANISATION : restreindre aux organisations gérées par l'utilisateur Optional> allowedOrgIds = getOrganisationIdsForCurrentUserIfAdminOrg(); if (allowedOrgIds.isPresent()) { Set ids = allowedOrgIds.get(); if (ids.isEmpty()) { return MembreSearchResultDTO.empty(criteria, page.size, page.index); } if (criteria.getOrganisationIds() == null || criteria.getOrganisationIds().isEmpty()) { criteria.setOrganisationIds(new ArrayList<>(ids)); } else { List intersection = criteria.getOrganisationIds().stream() .filter(ids::contains) .collect(Collectors.toList()); criteria.setOrganisationIds(intersection); } } // Construction de la requête dynamique StringBuilder queryBuilder = new StringBuilder("SELECT m FROM Membre m WHERE 1=1"); Map parameters = new HashMap<>(); // Ajout des critères de recherche addSearchCriteria(queryBuilder, parameters, criteria); // Requête pour compter le total String countQuery = queryBuilder .toString() .replace("SELECT m FROM Membre m", "SELECT COUNT(m) FROM Membre m"); // Exécution de la requête de comptage TypedQuery countQueryTyped = entityManager.createQuery(countQuery, Long.class); for (Map.Entry param : parameters.entrySet()) { countQueryTyped.setParameter(param.getKey(), param.getValue()); } long totalElements = countQueryTyped.getSingleResult(); if (totalElements == 0) { return MembreSearchResultDTO.empty(criteria, page.size, page.index); } // Ajout du tri et pagination String finalQuery = queryBuilder.toString(); if (sort != null) { finalQuery += " ORDER BY " + buildOrderByClause(sort); } // Exécution de la requête principale TypedQuery queryTyped = entityManager.createQuery(finalQuery, Membre.class); for (Map.Entry param : parameters.entrySet()) { queryTyped.setParameter(param.getKey(), param.getValue()); } queryTyped.setFirstResult(page.index * page.size); queryTyped.setMaxResults(page.size); List membres = queryTyped.getResultList(); // Conversion en SummaryResponses List membresDTO = convertToSummaryResponseList(membres); // Calcul des statistiques MembreSearchResultDTO.SearchStatistics statistics = calculateSearchStatistics(membres); // Construction du résultat MembreSearchResultDTO result = MembreSearchResultDTO.builder() .membres(membresDTO) .totalElements(totalElements) .totalPages((int) Math.ceil((double) totalElements / page.size)) .currentPage(page.index) .pageSize(page.size) .criteria(criteria) .statistics(statistics) .build(); // Calcul des indicateurs de pagination result.calculatePaginationFlags(); return result; } /** Ajoute les critères de recherche à la requête */ private void addSearchCriteria( StringBuilder queryBuilder, Map parameters, MembreSearchCriteria criteria) { // Recherche générale dans nom, prénom, email if (criteria.getQuery() != null) { queryBuilder.append( " AND (LOWER(m.nom) LIKE LOWER(:query) OR LOWER(m.prenom) LIKE LOWER(:query) OR" + " LOWER(m.email) LIKE LOWER(:query))"); parameters.put("query", "%" + criteria.getQuery() + "%"); } // Recherche par nom if (criteria.getNom() != null) { queryBuilder.append(" AND LOWER(m.nom) LIKE LOWER(:nom)"); parameters.put("nom", "%" + criteria.getNom() + "%"); } // Recherche par prénom if (criteria.getPrenom() != null) { queryBuilder.append(" AND LOWER(m.prenom) LIKE LOWER(:prenom)"); parameters.put("prenom", "%" + criteria.getPrenom() + "%"); } // Recherche par email if (criteria.getEmail() != null) { queryBuilder.append(" AND LOWER(m.email) LIKE LOWER(:email)"); parameters.put("email", "%" + criteria.getEmail() + "%"); } // Recherche par téléphone if (criteria.getTelephone() != null) { queryBuilder.append(" AND m.telephone LIKE :telephone"); parameters.put("telephone", "%" + criteria.getTelephone() + "%"); } // Filtre par statut if (criteria.getStatut() != null) { boolean isActif = "ACTIF".equals(criteria.getStatut()); queryBuilder.append(" AND m.actif = :actif"); parameters.put("actif", isActif); } else if (!Boolean.TRUE.equals(criteria.getIncludeInactifs())) { // Par défaut, exclure les inactifs queryBuilder.append(" AND m.actif = true"); } // Filtre par dates d'adhésion (via MembreOrganisation) if (criteria.getDateAdhesionMin() != null) { queryBuilder.append( " AND EXISTS (SELECT 1 FROM MembreOrganisation mo2 WHERE mo2.membre = m AND mo2.dateAdhesion >= :dateAdhesionMin)"); parameters.put("dateAdhesionMin", criteria.getDateAdhesionMin()); } if (criteria.getDateAdhesionMax() != null) { queryBuilder.append( " AND EXISTS (SELECT 1 FROM MembreOrganisation mo3 WHERE mo3.membre = m AND mo3.dateAdhesion <= :dateAdhesionMax)"); parameters.put("dateAdhesionMax", criteria.getDateAdhesionMax()); } // Filtre par âge (calculé à partir de la date de naissance) if (criteria.getAgeMin() != null) { LocalDate maxBirthDate = LocalDate.now().minusYears(criteria.getAgeMin()); queryBuilder.append(" AND m.dateNaissance <= :maxBirthDateForMinAge"); parameters.put("maxBirthDateForMinAge", maxBirthDate); } if (criteria.getAgeMax() != null) { LocalDate minBirthDate = LocalDate.now().minusYears(criteria.getAgeMax() + 1).plusDays(1); queryBuilder.append(" AND m.dateNaissance >= :minBirthDateForMaxAge"); parameters.put("minBirthDateForMaxAge", minBirthDate); } // Filtre par organisations (via MembreOrganisation) if (criteria.getOrganisationIds() != null && !criteria.getOrganisationIds().isEmpty()) { queryBuilder.append( " AND EXISTS (SELECT 1 FROM MembreOrganisation mo WHERE mo.membre = m AND mo.organisation.id IN :organisationIds)"); parameters.put("organisationIds", criteria.getOrganisationIds()); } // Filtre par rôles (via MembreOrganisation -> MembreRole) if (criteria.getRoles() != null && !criteria.getRoles().isEmpty()) { queryBuilder.append(" AND EXISTS ("); queryBuilder.append(" SELECT 1 FROM MembreRole mr WHERE mr.membreOrganisation.membre = m"); queryBuilder.append(" AND mr.actif = true"); queryBuilder.append(" AND mr.role.code IN :roleCodes"); queryBuilder.append(")"); parameters.put("roleCodes", criteria.getRoles()); } } /** Construit la clause ORDER BY à partir du Sort */ private String buildOrderByClause(Sort sort) { if (sort.getColumns().isEmpty()) { return "m.nom ASC"; } return sort.getColumns().stream() .map(column -> { String direction = column.getDirection() == Sort.Direction.Descending ? "DESC" : "ASC"; return "m." + column.getName() + " " + direction; }) .collect(Collectors.joining(", ")); } /** Calcule les statistiques sur les résultats de recherche */ private MembreSearchResultDTO.SearchStatistics calculateSearchStatistics(List membres) { if (membres.isEmpty()) { return MembreSearchResultDTO.SearchStatistics.builder() .membresActifs(0) .membresInactifs(0) .ageMoyen(0.0) .ageMin(0) .ageMax(0) .nombreOrganisations(0) .nombreRegions(0) .ancienneteMoyenne(0.0) .build(); } long membresActifs = membres.stream().mapToLong(m -> Boolean.TRUE.equals(m.getActif()) ? 1 : 0).sum(); long membresInactifs = membres.size() - membresActifs; // Calcul des âges List ages = membres.stream() .filter(m -> m.getDateNaissance() != null) .map(m -> Period.between(m.getDateNaissance(), LocalDate.now()).getYears()) .collect(Collectors.toList()); double ageMoyen = ages.stream().mapToInt(Integer::intValue).average().orElse(0.0); int ageMin = ages.stream().mapToInt(Integer::intValue).min().orElse(0); int ageMax = ages.stream().mapToInt(Integer::intValue).max().orElse(0); // Calcul de l'ancienneté moyenne double ancienneteMoyenne = 0.0; // calculé via MembreOrganisation // Nombre d'organisations via les membresOrganisations long nombreOrganisations = membres.stream() .flatMap(m -> m.getMembresOrganisations() != null ? m.getMembresOrganisations().stream() : java.util.stream.Stream.empty()) .map(mo -> mo.getOrganisation() != null ? mo.getOrganisation().getId() : null) .filter(java.util.Objects::nonNull) .distinct() .count(); return MembreSearchResultDTO.SearchStatistics.builder() .membresActifs(membresActifs) .membresInactifs(membresInactifs) .ageMoyen(ageMoyen) .ageMin(ageMin) .ageMax(ageMax) .nombreOrganisations(nombreOrganisations) .nombreRegions( membres.stream() .flatMap(m -> m.getAdresses() != null ? m.getAdresses().stream() : java.util.stream.Stream.empty()) .map(dev.lions.unionflow.server.entity.Adresse::getRegion) .filter(r -> r != null && !r.isEmpty()) .distinct() .count()) .ancienneteMoyenne(ancienneteMoyenne) .build(); } // ======================================== // MÉTHODES D'AUTOCOMPLÉTION (WOU/DRY) // ======================================== /** * Obtient la liste des villes distinctes depuis les adresses des membres * Réutilisable pour autocomplétion (WOU/DRY) */ public List obtenirVillesDistinctes(String query) { LOG.infof("Récupération des villes distinctes - query: %s", query); String jpql = "SELECT DISTINCT a.ville FROM Adresse a WHERE a.ville IS NOT NULL AND a.ville != ''"; if (query != null && !query.trim().isEmpty()) { jpql += " AND LOWER(a.ville) LIKE LOWER(:query)"; } jpql += " ORDER BY a.ville ASC"; TypedQuery typedQuery = entityManager.createQuery(jpql, String.class); if (query != null && !query.trim().isEmpty()) { typedQuery.setParameter("query", "%" + query.trim() + "%"); } typedQuery.setMaxResults(50); // Limiter à 50 résultats pour performance List villes = typedQuery.getResultList(); LOG.infof("Trouvé %d villes distinctes", villes.size()); return villes; } /** * Obtient la liste des professions distinctes depuis les membres * (autocomplétion). */ public List obtenirProfessionsDistinctes(String query) { LOG.infof("Récupération des professions distinctes - query: %s", query); String jpql = "SELECT DISTINCT m.profession FROM Membre m WHERE m.profession IS NOT NULL AND m.profession != ''"; if (query != null && !query.trim().isEmpty()) { jpql += " AND LOWER(m.profession) LIKE LOWER(:query)"; } jpql += " ORDER BY m.profession ASC"; TypedQuery typedQuery = entityManager.createQuery(jpql, String.class); if (query != null && !query.trim().isEmpty()) { typedQuery.setParameter("query", "%" + query.trim() + "%"); } typedQuery.setMaxResults(50); return typedQuery.getResultList(); } /** * Exporte une sélection de membres en Excel (WOU/DRY - réutilise la logique * d'export) * * @param membreIds Liste des IDs des membres à exporter * @param format Format d'export (EXCEL, CSV, etc.) * @return Données binaires du fichier Excel */ public byte[] exporterMembresSelectionnes(List membreIds, String format) { if (membreIds == null || membreIds.isEmpty()) { throw new IllegalArgumentException("La liste des membres ne peut pas être vide"); } LOG.infof("Export de %d membres sélectionnés - format: %s", membreIds.size(), format); // Récupérer les membres List membres = membreIds.stream() .map(id -> membreRepository.findByIdOptional(id)) .filter(opt -> opt.isPresent()) .map(java.util.Optional::get) .collect(Collectors.toList()); // Convertir en DTOs List membresDTO = convertToResponseList(membres); // Générer le fichier Excel (simplifié - à améliorer avec Apache POI) // Pour l'instant, générer un CSV simple StringBuilder csv = new StringBuilder(); csv.append("Numéro;Nom;Prénom;Email;Téléphone;Statut;Date Adhésion\n"); for (MembreResponse m : membresDTO) { csv.append( String.format( "%s;%s;%s;%s;%s;%s;%s\n", m.getNumeroMembre() != null ? m.getNumeroMembre() : "", m.getNom() != null ? m.getNom() : "", m.getPrenom() != null ? m.getPrenom() : "", m.getEmail() != null ? m.getEmail() : "", m.getTelephone() != null ? m.getTelephone() : "", m.getStatutCompte() != null ? m.getStatutCompte() : "", m.getDateAdhesion() != null ? m.getDateAdhesion().toString() : "")); } return csv.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8); } /** * Importe des membres depuis un fichier Excel ou CSV */ public MembreImportExportService.ResultatImport importerMembres( InputStream fileInputStream, String fileName, UUID organisationId, String typeMembreDefaut, boolean mettreAJourExistants, boolean ignorerErreurs) { return membreImportExportService.importerMembres( fileInputStream, fileName, organisationId, typeMembreDefaut, mettreAJourExistants, ignorerErreurs); } /** * Exporte des membres vers Excel */ public byte[] exporterVersExcel(List membres, List colonnesExport, boolean inclureHeaders, boolean formaterDates, boolean inclureStatistiques, String motDePasse) { try { return membreImportExportService.exporterVersExcel(membres, colonnesExport, inclureHeaders, formaterDates, inclureStatistiques, motDePasse); } catch (Exception e) { LOG.errorf(e, "Erreur lors de l'export Excel"); throw new RuntimeException("Erreur lors de l'export Excel: " + e.getMessage(), e); } } /** * Exporte des membres vers CSV */ public byte[] exporterVersCSV(List membres, List colonnesExport, boolean inclureHeaders, boolean formaterDates) { try { return membreImportExportService.exporterVersCSV(membres, colonnesExport, inclureHeaders, formaterDates); } catch (Exception e) { LOG.errorf(e, "Erreur lors de l'export CSV"); throw new RuntimeException("Erreur lors de l'export CSV: " + e.getMessage(), e); } } /** * Exporte des membres vers PDF */ public byte[] exporterVersPDF(List membres, List colonnesExport, boolean inclureHeaders, boolean formaterDates, boolean inclureStatistiques) { try { return membreImportExportService.exporterVersPDF(membres, colonnesExport, inclureHeaders, formaterDates, inclureStatistiques); } catch (Exception e) { LOG.errorf(e, "Erreur lors de l'export PDF"); throw new RuntimeException("Erreur lors de l'export PDF: " + e.getMessage(), e); } } /** * Génère un modèle Excel pour l'import */ public byte[] genererModeleImport() { try { return membreImportExportService.genererModeleImport(); } catch (Exception e) { LOG.errorf(e, "Erreur lors de la génération du modèle"); throw new RuntimeException("Erreur lors de la génération du modèle: " + e.getMessage(), e); } } /** * Liste les membres pour l'export selon les filtres */ public List listerMembresPourExport( UUID associationId, String statut, String type, String dateAdhesionDebut, String dateAdhesionFin) { List membres; if (associationId != null) { TypedQuery query = entityManager.createQuery( "SELECT DISTINCT m FROM Membre m JOIN m.membresOrganisations mo WHERE mo.organisation.id = :associationId", Membre.class); query.setParameter("associationId", associationId); membres = query.getResultList(); } else { membres = membreRepository.listAll(); } // Filtrer par statut if (statut != null && !statut.isEmpty()) { boolean actif = "ACTIF".equals(statut); membres = membres.stream() .filter(m -> m.getActif() == actif) .collect(Collectors.toList()); } return convertToResponseList(membres); } /** * Liste les membres appartenant aux organisations spécifiées (pour ADMIN_ORGANISATION) * * @param organisationIds Liste des IDs d'organisations * @param page Pagination * @param sort Tri * @return Liste des membres */ public List listerMembresParOrganisations( List organisationIds, Page page, Sort sort) { if (organisationIds == null || organisationIds.isEmpty()) { LOG.warn("listerMembresParOrganisations appelé avec liste vide"); return List.of(); } LOG.infof("Listage des membres pour %d organisations", organisationIds.size()); String jpql = "SELECT DISTINCT m FROM Membre m " + "JOIN m.membresOrganisations mo " + "WHERE mo.organisation.id IN :orgIds " + "AND (m.actif IS NULL OR m.actif = true OR m.statutCompte = 'EN_ATTENTE_VALIDATION') " + "ORDER BY m.nom ASC, m.prenom ASC"; TypedQuery query = entityManager.createQuery(jpql, Membre.class); query.setParameter("orgIds", organisationIds); if (page != null) { query.setFirstResult((int)page.index * page.size); query.setMaxResults(page.size); } List membres = query.getResultList(); LOG.infof("Trouvé %d membres pour les organisations spécifiées", membres.size()); return membres; } /** Compte le nombre total de membres pour les organisations données (même filtre que listerMembresParOrganisations). */ public long compterMembresParOrganisations(List organisationIds) { if (organisationIds == null || organisationIds.isEmpty()) return 0L; String jpql = "SELECT COUNT(DISTINCT m) FROM Membre m " + "JOIN m.membresOrganisations mo " + "WHERE mo.organisation.id IN :orgIds " + "AND (m.actif IS NULL OR m.actif = true OR m.statutCompte = 'EN_ATTENTE_VALIDATION')"; TypedQuery query = entityManager.createQuery(jpql, Long.class); query.setParameter("orgIds", organisationIds); return query.getSingleResult(); } /** * Vérifie si une organisation possède une souscription active. * Utilisé pour déterminer si un membre créé par un admin doit être auto-activé. * * @param orgId UUID de l'organisation * @return true si une souscription ACTIVE existe pour cette organisation */ public boolean orgHasActiveSubscription(UUID orgId) { if (orgId == null) return false; return entityManager.createQuery( "SELECT COUNT(s) FROM SouscriptionOrganisation s " + "WHERE s.organisation.id = :orgId AND s.statut = 'ACTIVE'", Long.class) .setParameter("orgId", orgId) .getSingleResult() > 0; } /** * Vérifie si une organisation a reçu un paiement (confirmé ou validé). * Utilisé pour auto-activer l'admin dès que le paiement est reçu, * sans attendre la validation super admin. * * @param orgId UUID de l'organisation * @return true si la souscription est ACTIVE ou en PAIEMENT_CONFIRME/VALIDEE */ public boolean orgHasPaidSubscription(UUID orgId) { if (orgId == null) return false; return entityManager.createQuery( "SELECT COUNT(s) FROM SouscriptionOrganisation s " + "WHERE s.organisation.id = :orgId " + "AND (s.statut = 'ACTIVE' OR s.statutValidation IN ('PAIEMENT_CONFIRME', 'VALIDEE'))", Long.class) .setParameter("orgId", orgId) .getSingleResult() > 0; } /** * Lie un membre à une organisation et incrémente le quota de la souscription. * Utilisé lors de la création unitaire ou de l'import massif. * * @param membre Membre à lier * @param organisationId ID de l'organisation * @param typeMembreDefaut Type de membre ("ACTIF", "EN_ATTENTE_VALIDATION", etc.) */ @Transactional public void lierMembreOrganisationEtIncrementerQuota( dev.lions.unionflow.server.entity.Membre membre, UUID organisationId, String typeMembreDefaut) { if (membre == null || organisationId == null) { throw new IllegalArgumentException("Membre et organisationId obligatoires"); } LOG.infof("Liaison membre %s à organisation %s", membre.getNumeroMembre(), organisationId); // Charger organisation dev.lions.unionflow.server.entity.Organisation organisation = entityManager.find(dev.lions.unionflow.server.entity.Organisation.class, organisationId); if (organisation == null) { throw new IllegalArgumentException("Organisation non trouvée: " + organisationId); } // Charger souscription active Optional souscriptionOpt = entityManager.createQuery( "SELECT s FROM SouscriptionOrganisation s " + "WHERE s.organisation.id = :orgId AND s.statut = 'ACTIVE'", dev.lions.unionflow.server.entity.SouscriptionOrganisation.class) .setParameter("orgId", organisationId) .getResultStream() .findFirst(); // Déterminer statut membre dev.lions.unionflow.server.api.enums.membre.StatutMembre statut = "ACTIF".equalsIgnoreCase(typeMembreDefaut) ? dev.lions.unionflow.server.api.enums.membre.StatutMembre.ACTIF : dev.lions.unionflow.server.api.enums.membre.StatutMembre.EN_ATTENTE_VALIDATION; // Créer lien MembreOrganisation dev.lions.unionflow.server.entity.MembreOrganisation membreOrganisation = new dev.lions.unionflow.server.entity.MembreOrganisation(); membreOrganisation.setMembre(membre); membreOrganisation.setOrganisation(organisation); membreOrganisation.setStatutMembre(statut); membreOrganisation.setDateAdhesion(LocalDate.now()); entityManager.persist(membreOrganisation); LOG.infof("MembreOrganisation créé (statut: %s)", statut); // Incrémenter le compteur nombreMembres de l'organisation organisation.ajouterMembre(); entityManager.persist(organisation); // Assigner le rôle SIMPLEMEMBER par défaut assignerRoleDefaut(membreOrganisation, "SIMPLEMEMBER"); // Vérifier quota et expiration avant d'incrémenter if (souscriptionOpt.isPresent()) { dev.lions.unionflow.server.entity.SouscriptionOrganisation souscription = souscriptionOpt.get(); // Vérifier que la souscription n'est pas expirée if (!souscription.isActive()) { throw new jakarta.ws.rs.ForbiddenException( "La souscription de l'organisation est expirée ou inactive. " + "Veuillez renouveler votre abonnement avant d'ajouter de nouveaux membres."); } // Vérifier que le quota n'est pas dépassé if (souscription.isQuotaDepasse()) { Integer max = souscription.getQuotaMax(); throw new jakarta.ws.rs.ForbiddenException( "Le quota de membres de votre plan est atteint (" + max + "/" + max + "). " + "Veuillez mettre à niveau votre formule d'abonnement."); } souscription.incrementerQuota(); entityManager.persist(souscription); LOG.infof("Quota souscription incrémenté (utilise: %d/%s)", souscription.getQuotaUtilise(), souscription.getQuotaMax() != null ? souscription.getQuotaMax().toString() : "∞"); } else { LOG.warn("Aucune souscription active trouvée pour organisation " + organisationId + " — ajout du membre sans vérification de quota"); } } private void assignerRoleDefaut(dev.lions.unionflow.server.entity.MembreOrganisation mo, String roleCode) { roleRepository.findByCode(roleCode).ifPresent(role -> { dev.lions.unionflow.server.entity.MembreRole membreRole = new dev.lions.unionflow.server.entity.MembreRole(); membreRole.setMembreOrganisation(mo); membreRole.setOrganisation(mo.getOrganisation()); membreRole.setRole(role); membreRole.setActif(true); membreRole.setDateDebut(LocalDate.now()); entityManager.persist(membreRole); LOG.infof("Rôle %s assigné au membre %s dans organisation %s", roleCode, mo.getMembre().getNumeroMembre(), mo.getOrganisation().getId()); }); } }