Files
unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/MembreService.java
dahoud 31330d95e9 feat: accumulated work — PI-SPI, KYC, RLS, mutuelle parts, comptabilité PDF + startup fixes
## PI-SPI BCEAO (P0.3 — deadline 30/06/2026)
- package payment/pispi/ complet : PispiAuth (OAuth2), PispiClient (HTTP brut),
  PispiIso20022Mapper (pacs.008/002), PispiSignatureVerifier (HMAC-SHA256),
  PispiWebhookResource (/api/pispi/webhook), DTOs ISO 20022
- PaymentOrchestrator + PaymentProviderRegistry pour l'orchestration multi-provider
- Mode mock automatique si credentials absents (dev)

## KYC AML
- entity/KycDossier, KycResource, KycAmlService + tests
- Migration V38 (create_kyc_dossier_table)

## RLS (PostgreSQL Row-Level Security) — isolation multi-tenant
- RlsConnectionInitializer, RlsContextInterceptor, @RlsEnabled annotation
- Migration V39 (PostgreSQL RLS Tenant Isolation) + V42 (app DB roles)
- Tests unitaires RlsConnectionInitializerTest, RlsContextInterceptorTest
- Tests d'intégration RlsCrossTenantIsolationTest (@QuarkusTest + IntegrationTestProfile)

## Mutuelle — Parts sociales
- entity/mutuelle/parts/ComptePartsSociales, TransactionPartsSociales
- Service, resource, mapper, repository + tests
- InteretsEpargneService + ReleveComptePdfService

## Comptabilité PDF
- ComptabilitePdfService (OpenPDF), ComptabilitePdfResource
- Tests ComptabilitePdfServiceTest, ComptabilitePdfResourceTest

## Migrations Flyway (SYSCOHADA + Keycloak Orgs)
- V36 SYSCOHADA Plan Comptable Complet : seeds comptes standards UEMOA,
  trigger init_plan_comptable_organisation, alignement schéma V1 → entités
- V37 keycloak_org_id sur organisations (P0.2 migration KC 26)
- V40 provider_defaut sur FormuleAbonnement
- V41 fcm_token sur utilisateurs (FCM notifications push)

## Fixes startup (SmallRye Config 3.20 + schéma)
- 8× @ConfigProperty(defaultValue = "") → Optional<String>
  (firebase, pispi.*, mtnmomo, orange) — empty default rejetés par SmallRye 3.20
- application.properties : mappings secrets env var sous %prod. uniquement
- V36 : drop colonne obsolète 'numero' de V1 quand Hibernate a créé 'numero_compte'
- V36 : remplacement UNIQUE global sur journaux_comptables.code par composite
  (organisation_id, code) pour autoriser plusieurs orgs avec code 'ACH'/'VTE'/etc
- V39 : escape placeholder ${VAR} → <VAR> dans lignes commentées
  (Flyway parser évalue les placeholders même dans les commentaires)
- V41 : table 'membres' → 'utilisateurs' (nom correct selon entité Membre)
- JournalComptable entity : @UniqueConstraint composite au lieu de unique=true
- MembreResource : example @Schema JSON valide (['...'] → [])
- IntegrationTestProfile : auto-détection Docker via `docker info`, fallback
  vers PostgreSQL local sans DevServices

## Dev config
- application-dev.properties : quarkus.devservices.enabled=false +
  quarkus.kafka.devservices.enabled=false (pas besoin de Docker pour dev)
- quarkus.flyway.placeholder-replacement=false
- Secrets dev (wave.*, firebase, pispi) en mode mock automatique

## Phase 8 tests (complète)
- 170 fichiers modifiés/ajoutés, 23425+ insertions
- Tests RBAC (@QuarkusTest) pour MembreResource lifecycle
- Tests OrganisationContextFilter multi-org
- Tests SouscriptionQuotaOptionC, KycAmlService, EmailTemplate, etc.

Résultat : Backend démarre en 64s sur port 8085 avec 36 features installées.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:40:55 +00:00

1412 lines
57 KiB
Java

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<String, Object> 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<String, Object> 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<Membre> trouverParId(UUID id) {
return Optional.ofNullable(membreRepository.findById(id));
}
/** Trouve un membre par son email */
public Optional<Membre> trouverParEmail(String email) {
return membreRepository.findByEmail(email);
}
/** Trouve un membre par son numéro de membre (ex: MBR-0001) */
public Optional<Membre> trouverParNumeroMembre(String numeroMembre) {
return membreRepository.findByNumeroMembre(numeroMembre);
}
/** Liste tous les membres actifs */
public List<Membre> listerMembresActifs() {
return membreRepository.findAllActifs();
}
/** Recherche des membres par nom ou prénom */
public List<Membre> rechercherMembres(String recherche) {
return membreRepository.findByNomOrPrenom(recherche);
}
/**
* Désactive un membre avec propagation complète des cascades métier.
*
* <p>Garde-fous et effets :
* <ol>
* <li><b>Check mono-admin</b> : 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.</li>
* <li>DB : {@code actif=false}, {@code statutCompte='DESACTIVE'}</li>
* <li>Toutes les adhésions actives → {@code SUSPENDU}, {@code nombreMembres} décrémenté</li>
* <li>Tous les {@link dev.lions.unionflow.server.entity.MembreRole} → {@code actif=false}
* (perte immédiate des droits fonctionnels)</li>
* <li>Keycloak (lions-user-manager) : {@code user.enabled=false} → login impossible</li>
* <li>Kafka : événement {@code member.deactivated} émis pour les consommateurs externes</li>
* </ol>
*
* <p>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<String> 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<String> verifierOrgsOrphelinees(UUID membreId) {
List<String> orphelines = new ArrayList<>();
// Toutes les orgs où ce membre est ORGADMIN actif
final LocalDate today = LocalDate.now();
List<dev.lions.unionflow.server.entity.MembreRole> 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<Membre> 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<Membre> listerMembres(Page page, Sort sort) {
Optional<Set<UUID>> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg();
if (orgIds.isPresent()) {
Set<UUID> 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<Set<UUID>> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg();
if (orgIds.isPresent()) {
Set<UUID> 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<Membre> rechercherMembres(String recherche, Page page, Sort sort) {
Optional<Set<UUID>> orgIds = getOrganisationIdsForCurrentUserIfAdminOrg();
if (orgIds.isPresent()) {
Set<UUID> 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<Set<UUID>> getOrganisationIdsForCurrentUserIfAdminOrg() {
if (securityIdentity.getPrincipal() == null) return Optional.empty();
Set<String> 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<dev.lions.unionflow.server.entity.Organisation> orgs = organisationService.listerOrganisationsPourUtilisateur(email);
if (orgs == null || orgs.isEmpty()) return Optional.of(Set.of());
Set<UUID> 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<String, Object> 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<String, Object> 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<dev.lions.unionflow.server.entity.MembreRole> roles = membreRoleRepository
.findActifsByMembreId(membre.getId());
if (!roles.isEmpty()) {
List<String> 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<String> rolesNames = new ArrayList<>();
List<dev.lions.unionflow.server.entity.MembreRole> 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<MembreSummaryResponse> convertToSummaryResponseList(List<Membre> 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<MembreResponse> convertToResponseList(List<Membre> 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<Membre> 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<Set<UUID>> allowedOrgIds = getOrganisationIdsForCurrentUserIfAdminOrg();
if (allowedOrgIds.isPresent()) {
Set<UUID> 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<UUID> 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<String, Object> 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<Long> countQueryTyped = entityManager.createQuery(countQuery, Long.class);
for (Map.Entry<String, Object> 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<Membre> queryTyped = entityManager.createQuery(finalQuery, Membre.class);
for (Map.Entry<String, Object> param : parameters.entrySet()) {
queryTyped.setParameter(param.getKey(), param.getValue());
}
queryTyped.setFirstResult(page.index * page.size);
queryTyped.setMaxResults(page.size);
List<Membre> membres = queryTyped.getResultList();
// Conversion en SummaryResponses
List<MembreSummaryResponse> 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<String, Object> 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<Membre> 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<Integer> 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<String> 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<String> 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<String> 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<String> 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<String> 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<UUID> 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<Membre> membres = membreIds.stream()
.map(id -> membreRepository.findByIdOptional(id))
.filter(opt -> opt.isPresent())
.map(java.util.Optional::get)
.collect(Collectors.toList());
// Convertir en DTOs
List<MembreResponse> 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<MembreResponse> membres, List<String> 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<MembreResponse> membres, List<String> 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<MembreResponse> membres, List<String> 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<MembreResponse> listerMembresPourExport(
UUID associationId,
String statut,
String type,
String dateAdhesionDebut,
String dateAdhesionFin) {
List<Membre> membres;
if (associationId != null) {
TypedQuery<Membre> 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<Membre> listerMembresParOrganisations(
List<UUID> 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<Membre> 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<Membre> 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<UUID> 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<Long> 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<dev.lions.unionflow.server.entity.SouscriptionOrganisation> 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());
});
}
}