## 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>
1412 lines
57 KiB
Java
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());
|
|
});
|
|
}
|
|
}
|