Files
unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/entity/Organisation.java
lionsdev d8006c8425
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
feat(p0-2026-04-25): multi-référentiel comptable + UBO + audit trail + SoD + seuils AML
Sprint 1 P0 (consolidation 2026-04-25, ETAT_PROJET_METIER_2026-04-25.md) :

P0-NEW-9/10/11 — Multi-référentiel comptable
  - enum ReferentielComptable (SYSCOHADA / SYCEBNL / PCSFD_UMOA)
  - Organisation.referentielComptable + mapping defaultFor(typeOrganisation)
  - V43 : colonne + check + index + mapping initial des orgs existantes

P0-NEW-13 — Bénéficiaires effectifs (UBO) — Instruction BCEAO 003-03-2025
  - Entité BeneficiaireEffectif + repository
  - V44 : table beneficiaires_effectifs (FK kyc_dossier, UBO + PEP + sanctions)
  - Conservation 10 ans (directive 02/2015/CM/UEMOA)

P0-NEW-14 — Compliance Officer (Instruction BCEAO 001-03-2025)
  - Organisation.complianceOfficerId + V43 colonne + index

P0-NEW-15 — Seuils AML alignés (Instruction BCEAO 002-03-2025)
  - AmlSeuils : 10M FCFA intra-UEMOA / 5M FCFA entrée-sortie / 1M FCFA espèce
  - Liste pays UEMOA ISO 3166-1
  - Méthodes seuilApplicable() / depasseSeuil() / depasseSeuilEspece()

P0-NEW-17/18 — Rôles PRESIDENT + CONTROLEUR_INTERNE + suppléants
  - V45 seed : PRESIDENT, VICE_PRESIDENT, CONTROLEUR_INTERNE, ANIMATEUR_ZONE, SECRETAIRE_ADJOINT, TRESORIER_ADJOINT
  - Catégories GOUVERNANCE / CONTROLE / OPERATIONNEL

P0-NEW-19 — Audit trail enrichi (SYSCOHADA + AUDSCGIE)
  - V45 : table audit_trail_operations (acteur, action, contexte multi-org, payload JSONB, SoD)
  - Entité AuditTrailOperation + AuditTrailOperationRepository
  - AuditTrailService (log avec contexte automatique depuis OrganisationContextHolder)
  - OrganisationContextHolder enrichi (roleActif, currentUserId, currentUserEmail)

P0-NEW-20 — SoD (Separation of Duties) — SYSCOHADA + AUDSCGIE + BCEAO Circulaire 03-2017
  - SoDPermissionChecker.checkValidationDistinct() (4-eyes principle)
  - .checkRoleCombination() (combinaisons interdites : Trésorier+Président, etc.)
  - .checkComplianceOfficerEligibility() (Instruction BCEAO 001-03-2025)
  - SoDCheckResult record avec audit trail automatique

P0-NEW-24 — Champ numero_cmu sur Membre (Loi 2014-131 CI)
  - Membre.numeroCMU + V43 colonne + check format 11 caractères + index
  - Auto-déclaration (pas d'API publique CNAM disponible)

BUILD SUCCESS.
2026-04-25 01:15:25 +00:00

356 lines
11 KiB
Java

package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.Period;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Entité Organisation avec UUID Représente une organisation (Lions Club,
* Association,
* Coopérative, etc.)
*
* @author UnionFlow Team
* @version 2.0
* @since 2025-01-16
*/
@Entity
@Table(name = "organisations", indexes = {
@Index(name = "idx_organisation_nom", columnList = "nom"),
@Index(name = "idx_organisation_email", columnList = "email", unique = true),
@Index(name = "idx_organisation_statut", columnList = "statut"),
@Index(name = "idx_organisation_type", columnList = "type_organisation"),
@Index(name = "idx_organisation_parente", columnList = "organisation_parente_id"),
@Index(name = "idx_organisation_numero_enregistrement", columnList = "numero_enregistrement", unique = true)
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Organisation extends BaseEntity {
@NotBlank
@Column(name = "nom", nullable = false, length = 200)
private String nom;
@Column(name = "nom_court", length = 50)
private String nomCourt;
@NotBlank
@Column(name = "type_organisation", nullable = false, length = 50)
private String typeOrganisation;
@NotBlank
@Column(name = "statut", nullable = false, length = 50)
private String statut;
@Column(name = "description", length = 2000)
private String description;
@Column(name = "date_fondation")
private LocalDate dateFondation;
@Column(name = "numero_enregistrement", unique = true, length = 100)
private String numeroEnregistrement;
// Informations de contact
@Email
@NotBlank
@Column(name = "email", unique = true, nullable = false, length = 255)
private String email;
@Column(name = "telephone", length = 20)
private String telephone;
@Column(name = "telephone_secondaire", length = 20)
private String telephoneSecondaire;
@Email
@Column(name = "email_secondaire", length = 255)
private String emailSecondaire;
// Adresse principale (champs dénormalisés pour performance)
@Column(name = "adresse", length = 500)
private String adresse;
@Column(name = "ville", length = 100)
private String ville;
@Column(name = "region", length = 100)
private String region;
@Column(name = "pays", length = 100)
private String pays;
@Column(name = "code_postal", length = 20)
private String codePostal;
// Coordonnées géographiques
@DecimalMin(value = "-90.0", message = "La latitude doit être comprise entre -90 et 90")
@DecimalMax(value = "90.0", message = "La latitude doit être comprise entre -90 et 90")
@Digits(integer = 3, fraction = 6)
@Column(name = "latitude", precision = 9, scale = 6)
private BigDecimal latitude;
@DecimalMin(value = "-180.0", message = "La longitude doit être comprise entre -180 et 180")
@DecimalMax(value = "180.0", message = "La longitude doit être comprise entre -180 et 180")
@Digits(integer = 3, fraction = 6)
@Column(name = "longitude", precision = 9, scale = 6)
private BigDecimal longitude;
// Web et réseaux sociaux
@Column(name = "site_web", length = 500)
private String siteWeb;
@Column(name = "logo", length = 500)
private String logo;
@Column(name = "reseaux_sociaux", length = 1000)
private String reseauxSociaux;
// ── Hiérarchie ──────────────────────────────────────────────────────────────
/** Organisation parente — FK propre (null = organisation racine) */
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_parente_id")
private Organisation organisationParente;
@Builder.Default
@Column(name = "niveau_hierarchique", nullable = false)
private Integer niveauHierarchique = 0;
/**
* TRUE si c'est l'organisation racine qui porte la souscription SaaS
* pour toute sa hiérarchie.
*/
@Builder.Default
@Column(name = "est_organisation_racine", nullable = false)
private Boolean estOrganisationRacine = true;
/**
* Chemin hiérarchique complet — ex: /uuid-racine/uuid-intermediate/uuid-feuille
* Permet des requêtes récursives optimisées sans CTE.
*/
@Column(name = "chemin_hierarchique", length = 2000)
private String cheminHierarchique;
// Statistiques
@Builder.Default
@Column(name = "nombre_membres", nullable = false)
private Integer nombreMembres = 0;
@Builder.Default
@Column(name = "nombre_administrateurs", nullable = false)
private Integer nombreAdministrateurs = 0;
// Finances
@DecimalMin(value = "0.0", message = "Le budget annuel doit être positif")
@Digits(integer = 12, fraction = 2)
@Column(name = "budget_annuel", precision = 14, scale = 2)
private BigDecimal budgetAnnuel;
@Builder.Default
@Column(name = "devise", length = 3)
private String devise = "XOF";
@Builder.Default
@Column(name = "cotisation_obligatoire", nullable = false)
private Boolean cotisationObligatoire = false;
@DecimalMin(value = "0.0", message = "Le montant de cotisation doit être positif")
@Digits(integer = 10, fraction = 2)
@Column(name = "montant_cotisation_annuelle", precision = 12, scale = 2)
private BigDecimal montantCotisationAnnuelle;
// Informations complémentaires
@Column(name = "objectifs", length = 2000)
private String objectifs;
@Column(name = "activites_principales", length = 2000)
private String activitesPrincipales;
@Column(name = "certifications", length = 500)
private String certifications;
@Column(name = "partenaires", length = 1000)
private String partenaires;
@Column(name = "notes", length = 1000)
private String notes;
// Paramètres
@Builder.Default
@Column(name = "organisation_publique", nullable = false)
private Boolean organisationPublique = true;
@Builder.Default
@Column(name = "accepte_nouveaux_membres", nullable = false)
private Boolean accepteNouveauxMembres = true;
/** Catégorie du type d'organisation (ASSOCIATIF, FINANCIER_SOLIDAIRE, RELIGIEUX, PROFESSIONNEL, RESEAU_FEDERATION) */
@Column(name = "categorie_type", length = 50)
private String categorieType;
/** ID de l'Organization Keycloak 26 correspondante — null si pas encore migrée. */
@Column(name = "keycloak_org_id")
private UUID keycloakOrgId;
/** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */
@Column(name = "modules_actifs", length = 1000)
private String modulesActifs;
/**
* Référentiel comptable applicable à cette organisation.
*
* <p>Détermine quel plan comptable est appliqué et quels états financiers sont générés
* (bilan, compte de résultat, annexes). Mappage par défaut depuis {@code typeOrganisation}
* via {@link ReferentielComptable#defaultFor(String)} ; l'admin peut overrider manuellement.
*
* @since 2026-04-25 — découverte SYCEBNL (11ᵉ Acte uniforme OHADA en vigueur 1er jan 2024)
*/
@Enumerated(EnumType.STRING)
@Column(name = "referentiel_comptable", nullable = false, length = 20)
@Builder.Default
private ReferentielComptable referentielComptable = ReferentielComptable.SYSCOHADA;
/**
* UUID du membre désigné comme Compliance Officer de l'organisation (rôle obligatoire selon
* Instruction BCEAO 001-03-2025). Doit être rattaché à la direction générale, distinct du
* trésorier (séparation des pouvoirs).
*
* @since 2026-04-25 — Instruction BCEAO 001-03-2025 (LBC/FT)
*/
@Column(name = "compliance_officer_id")
private UUID complianceOfficerId;
// Relations
/** Adhésions des membres à cette organisation */
@JsonIgnore
@OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<MembreOrganisation> membresOrganisations = new ArrayList<>();
@JsonIgnore
@OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<Adresse> adresses = new ArrayList<>();
@JsonIgnore
@OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<CompteWave> comptesWave = new ArrayList<>();
/** Méthode métier pour obtenir le nom complet avec sigle */
public String getNomComplet() {
if (nomCourt != null && !nomCourt.isEmpty()) {
return nom + " (" + nomCourt + ")";
}
return nom;
}
/** Méthode métier pour calculer l'ancienneté en années */
public int getAncienneteAnnees() {
if (dateFondation == null) {
return 0;
}
return Period.between(dateFondation, LocalDate.now()).getYears();
}
/**
* Méthode métier pour vérifier si l'organisation est récente (moins de 2 ans)
*/
public boolean isRecente() {
return getAncienneteAnnees() < 2;
}
/** Méthode métier pour vérifier si l'organisation est active */
public boolean isActive() {
return "ACTIVE".equals(statut) && Boolean.TRUE.equals(getActif());
}
/** Méthode métier pour ajouter un membre */
public void ajouterMembre() {
if (nombreMembres == null) {
nombreMembres = 0;
}
nombreMembres++;
}
/** Méthode métier pour retirer un membre */
public void retirerMembre() {
if (nombreMembres != null && nombreMembres > 0) {
nombreMembres--;
}
}
/** Méthode métier pour activer l'organisation */
public void activer(String utilisateur) {
this.statut = "ACTIVE";
this.setActif(true);
marquerCommeModifie(utilisateur);
}
/** Méthode métier pour suspendre l'organisation */
public void suspendre(String utilisateur) {
this.statut = "SUSPENDUE";
this.accepteNouveauxMembres = false;
marquerCommeModifie(utilisateur);
}
/** Méthode métier pour dissoudre l'organisation */
public void dissoudre(String utilisateur) {
this.statut = "DISSOUTE";
this.setActif(false);
this.accepteNouveauxMembres = false;
marquerCommeModifie(utilisateur);
}
/** Callback JPA avant la persistance */
@PrePersist
protected void onCreate() {
super.onCreate(); // Appelle le onCreate de BaseEntity
if (statut == null) {
statut = "ACTIVE";
}
if (typeOrganisation == null) {
typeOrganisation = "ASSOCIATION";
}
if (devise == null) {
devise = "XOF";
}
if (niveauHierarchique == null) {
niveauHierarchique = 0;
}
if (estOrganisationRacine == null) {
estOrganisationRacine = (organisationParente == null);
}
if (nombreMembres == null) {
nombreMembres = 0;
}
if (nombreAdministrateurs == null) {
nombreAdministrateurs = 0;
}
if (organisationPublique == null) {
organisationPublique = true;
}
if (accepteNouveauxMembres == null) {
accepteNouveauxMembres = true;
}
if (cotisationObligatoire == null) {
cotisationObligatoire = false;
}
}
}