feat(sprint-2 P0+P1 2026-04-25): DOS CENTIF + Reporting AIRMS + PV OHADA + workflow demande aide v2 + délégation rôles + SYCEBNL donateurs + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m10s
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m10s
P0-NEW-16 — DOS CENTIF (Lignes directrices CENTIF mars 2025)
- DosCentifService.genererDosWord() : XWPFDocument structurée 6 sections
- DosCentifService.genererReleveExcel() : XSSFWorkbook avec opérations atypiques
- DosCentifResource @RolesAllowed COMPLIANCE_OFFICER : POST /api/aml/dos/{word,excel}
- Confidentialité absolue (jamais persisté disque, audit trail EXPORT)
P1-NEW-1 — Reporting AIRMS triple
- RapportAirmsService.genererRapportTriple() : OpenPDF, 3 sections + cover
- DTOs records : RapportTechnique (effectifs, sinistralité, délais),
RapportMoral (vie associative, formations, activités),
RapportFinancier (P&L, ratios prudentiels, trésorerie, fonds dédiés SYCEBNL)
- Endpoint POST /api/airms/rapports/triple
P1-NEW-2 — Module PV OHADA (AG/CA)
- V46 : table proces_verbaux (quorum, OJ JSONB, résolutions JSONB, hash SHA-256, signatures)
- Entité ProcesVerbal + repo ProcesVerbalRepository
- ProcesVerbalService :
* quorumRequisDefaut() : 50% AG ord / 66.67% AG extra/CA
* calculerEtFixerQuorum() : (présents+représentés)/convoqués × 100
* adopter() : valide quorum, fige hash SHA-256
* signer() : vérif hash inchangé (immuabilité), signature électronique
* archiver() : statut ARCHIVE pour conservation OHADA 10 ans
P1-NEW-3 — Workflow demande d'aide v2
- V46 : extension demandes_aide (etape, animateur_zone, gps_enquete, avis_comite, decision_ca)
- V46 : extension types_aide (plafond_annuel_membre, plafond_enveloppe_annuelle, justificatifs_requis)
- DemandeAide enrichie : 5 étapes DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
- DemandeAideV2Service :
* Transitions typées avec checks d'état
* SoD : approbateur CA ≠ animateur enquête
* Audit trail enrichi à chaque transition
P1-NEW-5 — Délégation temporaire rôles
- V46 : table role_delegations (delegant, delegataire, role, dates, statut, motif)
- Entité RoleDelegation + isActiveAt(instant)
- RoleDelegationService :
* creer() : vérif SoD avant création (pas de conflit avec rôles existants délégataire)
* revoquer() : statut REVOQUEE
* rolesEffectifs() : directs ∪ délégués actifs
* marquerExpirees() : scheduler quotidien
P1-NEW-13 — Registre donateurs SYCEBNL
- V46 : tables donateurs + dons_recus (numéraire/nature/bénévolat/legs)
- Affectation : LIBRE / FONDS_DEDIE / PROJET_SPECIFIQUE
- Reçu fiscal (numero_recu, date_emission_recu)
- Entités Donateur + DonRecu + repos
- DonRecuRepository.totalEntre() pour reporting AIRMS/SYCEBNL
P1-NEW-14 — Membres honoraires/bienfaiteurs
- V46 : ALTER membres_organisations.qualite_speciale (HONORAIRE/BIENFAITEUR/FONDATEUR)
Tests Sprint 2 (13 nouveaux, 0 failure) :
- ProcesVerbalServiceTest (8) : quorum défaut, atteint/non-atteint, hash format/immuabilité/diff
- RoleDelegationTest (5) : isActiveAt selon période et statut
This commit is contained in:
@@ -76,6 +76,53 @@ public class DemandeAide extends BaseEntity {
|
|||||||
@Column(name = "documents_fournis")
|
@Column(name = "documents_fournis")
|
||||||
private String documentsFournis;
|
private String documentsFournis;
|
||||||
|
|
||||||
|
// ========================================================
|
||||||
|
// Workflow v2 (P1-NEW-3, 2026-04-25) — DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
|
||||||
|
// ========================================================
|
||||||
|
|
||||||
|
/** Étape actuelle dans le workflow v2 (DEPOSE par défaut). */
|
||||||
|
@Column(name = "etape", length = 30)
|
||||||
|
@Builder.Default
|
||||||
|
private String etape = "DEPOSE";
|
||||||
|
|
||||||
|
/** Animateur de zone responsable de l'enquête sociale (étape ENQUETE). */
|
||||||
|
@Column(name = "animateur_zone_id")
|
||||||
|
private java.util.UUID animateurZoneId;
|
||||||
|
|
||||||
|
/** Rapport rédigé par l'animateur après visite (étape ENQUETE). */
|
||||||
|
@Column(name = "rapport_enquete_sociale", columnDefinition = "TEXT")
|
||||||
|
private String rapportEnqueteSociale;
|
||||||
|
|
||||||
|
@Column(name = "date_enquete")
|
||||||
|
private LocalDateTime dateEnquete;
|
||||||
|
|
||||||
|
/** Géolocalisation GPS de l'enquête (preuve de visite terrain). */
|
||||||
|
@Column(name = "gps_enquete_lat", precision = 10, scale = 7)
|
||||||
|
private java.math.BigDecimal gpsEnqueteLat;
|
||||||
|
|
||||||
|
@Column(name = "gps_enquete_lon", precision = 10, scale = 7)
|
||||||
|
private java.math.BigDecimal gpsEnqueteLon;
|
||||||
|
|
||||||
|
/** Avis du comité social ou commission solidarité (étape AVIS_COMITE). */
|
||||||
|
@Column(name = "avis_comite_social", columnDefinition = "TEXT")
|
||||||
|
private String avisComiteSocial;
|
||||||
|
|
||||||
|
@Column(name = "date_avis_comite")
|
||||||
|
private LocalDateTime dateAvisComite;
|
||||||
|
|
||||||
|
/** Lien vers le PV CA dans lequel la décision a été votée (étape DECISION_CA). */
|
||||||
|
@Column(name = "decision_ca_id")
|
||||||
|
private java.util.UUID decisionCaId;
|
||||||
|
|
||||||
|
@Column(name = "date_decision_ca")
|
||||||
|
private LocalDateTime dateDecisionCa;
|
||||||
|
|
||||||
|
@Column(name = "date_paie")
|
||||||
|
private LocalDateTime datePaie;
|
||||||
|
|
||||||
|
@Column(name = "reference_paiement", length = 100)
|
||||||
|
private String referencePaiement;
|
||||||
|
|
||||||
@PrePersist
|
@PrePersist
|
||||||
protected void onCreate() {
|
protected void onCreate() {
|
||||||
super.onCreate(); // Appelle le onCreate de BaseEntity
|
super.onCreate(); // Appelle le onCreate de BaseEntity
|
||||||
|
|||||||
86
src/main/java/dev/lions/unionflow/server/entity/DonRecu.java
Normal file
86
src/main/java/dev/lions/unionflow/server/entity/DonRecu.java
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Don reçu (numéraire, nature, bénévolat, legs) — comptabilisé selon SYCEBNL :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>NUMERAIRE → Crédit 755 (Dons et libéralités)
|
||||||
|
* <li>NATURE → valorisation obligatoire au prix marché, idem 755
|
||||||
|
* <li>BENEVOLAT → valorisation possible en notes annexes
|
||||||
|
* <li>LEGS → Crédit 756 ou poste dédié selon nature
|
||||||
|
* <li>FONDS_DEDIE → Crédit 19 (Fonds dédiés non utilisés, à reverser si finalité non remplie)
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-13)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "dons_recus", indexes = {
|
||||||
|
@Index(name = "idx_don_org", columnList = "organisation_id"),
|
||||||
|
@Index(name = "idx_don_donateur", columnList = "donateur_id"),
|
||||||
|
@Index(name = "idx_don_date", columnList = "date_don")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class DonRecu extends BaseEntity {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "organisation_id", nullable = false)
|
||||||
|
private UUID organisationId;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "donateur_id")
|
||||||
|
private Donateur donateur;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "type_don", nullable = false, length = 20)
|
||||||
|
private String typeDon; // NUMERAIRE, NATURE, BENEVOLAT, LEGS
|
||||||
|
|
||||||
|
@Column(name = "montant_xof", precision = 15, scale = 2)
|
||||||
|
private BigDecimal montantXof;
|
||||||
|
|
||||||
|
@Column(name = "valorisation_xof", precision = 15, scale = 2)
|
||||||
|
private BigDecimal valorisationXof;
|
||||||
|
|
||||||
|
@Column(name = "description", columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "date_don", nullable = false)
|
||||||
|
private LocalDate dateDon;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "affectation", nullable = false, length = 50)
|
||||||
|
@Builder.Default
|
||||||
|
private String affectation = "LIBRE"; // LIBRE, FONDS_DEDIE, PROJET_SPECIFIQUE
|
||||||
|
|
||||||
|
@Column(name = "fonds_dedie_id")
|
||||||
|
private UUID fondsDedieId;
|
||||||
|
|
||||||
|
@Column(name = "projet_id")
|
||||||
|
private UUID projetId;
|
||||||
|
|
||||||
|
@Column(name = "recu_emis", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean recuEmis = false;
|
||||||
|
|
||||||
|
@Column(name = "numero_recu", length = 50)
|
||||||
|
private String numeroRecu;
|
||||||
|
|
||||||
|
@Column(name = "date_emission_recu")
|
||||||
|
private LocalDate dateEmissionRecu;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Donateur — registre obligatoire pour les entités relevant de SYCEBNL (associations, ONG,
|
||||||
|
* mutuelles sociales).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-13)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "donateurs", indexes = {
|
||||||
|
@Index(name = "idx_donateur_org", columnList = "organisation_id"),
|
||||||
|
@Index(name = "idx_donateur_type", columnList = "type_donateur")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class Donateur extends BaseEntity {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "organisation_id", nullable = false)
|
||||||
|
private UUID organisationId;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "type_donateur", nullable = false, length = 20)
|
||||||
|
private String typeDonateur; // PERSONNE_PHYSIQUE, PERSONNE_MORALE, ANONYME
|
||||||
|
|
||||||
|
@Column(name = "nom_prenoms", length = 255)
|
||||||
|
private String nomPrenoms;
|
||||||
|
|
||||||
|
@Column(name = "raison_sociale", length = 255)
|
||||||
|
private String raisonSociale;
|
||||||
|
|
||||||
|
@Column(name = "pays", length = 3)
|
||||||
|
private String pays;
|
||||||
|
|
||||||
|
@Column(name = "email", length = 255)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(name = "telephone", length = 20)
|
||||||
|
private String telephone;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.JdbcTypeCode;
|
||||||
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procès-verbal d'AG ou de CA conforme OHADA AUDSCGIE.
|
||||||
|
*
|
||||||
|
* <p>Structure obligatoire selon l'Acte Uniforme OHADA Sociétés Coopératives (15 décembre 2010,
|
||||||
|
* applicable depuis 15 mai 2011) + AUDSCGIE révisé 30 janvier 2014 :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Date, lieu, heures d'ouverture et clôture
|
||||||
|
* <li>Quorum calculé (selon convoqués / présents / représentés)
|
||||||
|
* <li>Ordre du jour structuré
|
||||||
|
* <li>Résolutions votées avec décompte (pour / contre / abstentions / adoptée)
|
||||||
|
* <li>Signatures président + secrétaire
|
||||||
|
* <li>Archivage immuable au siège (hash SHA-256 pour intégrité)
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-2)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "proces_verbaux")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ProcesVerbal extends BaseEntity {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "organisation_id", nullable = false)
|
||||||
|
private UUID organisationId;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "type_seance", nullable = false, length = 20)
|
||||||
|
private String typeSeance; // AG_CONSTITUTIVE, AG_ORDINAIRE, AG_EXTRAORDINAIRE, CA, BUREAU
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "titre", nullable = false, length = 255)
|
||||||
|
private String titre;
|
||||||
|
|
||||||
|
@Column(name = "numero_seance", length = 50)
|
||||||
|
private String numeroSeance;
|
||||||
|
|
||||||
|
// Convocation
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "date_convocation", nullable = false)
|
||||||
|
private LocalDateTime dateConvocation;
|
||||||
|
|
||||||
|
@Column(name = "mode_convocation", length = 50)
|
||||||
|
private String modeConvocation;
|
||||||
|
|
||||||
|
// Tenue
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "date_seance", nullable = false)
|
||||||
|
private LocalDateTime dateSeance;
|
||||||
|
|
||||||
|
@Column(name = "lieu", length = 255)
|
||||||
|
private String lieu;
|
||||||
|
|
||||||
|
@Column(name = "heure_ouverture")
|
||||||
|
private LocalTime heureOuverture;
|
||||||
|
|
||||||
|
@Column(name = "heure_cloture")
|
||||||
|
private LocalTime heureCloture;
|
||||||
|
|
||||||
|
// Quorum
|
||||||
|
@Column(name = "nombre_convoques", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private int nombreConvoques = 0;
|
||||||
|
|
||||||
|
@Column(name = "nombre_presents", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private int nombrePresents = 0;
|
||||||
|
|
||||||
|
@Column(name = "nombre_representes", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private int nombreRepresentes = 0;
|
||||||
|
|
||||||
|
@Column(name = "quorum_atteint", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean quorumAtteint = false;
|
||||||
|
|
||||||
|
@Column(name = "quorum_requis_pct", precision = 5, scale = 2)
|
||||||
|
private BigDecimal quorumRequisPct;
|
||||||
|
|
||||||
|
@Column(name = "quorum_calcule_pct", precision = 5, scale = 2)
|
||||||
|
private BigDecimal quorumCalculePct;
|
||||||
|
|
||||||
|
// Présidence
|
||||||
|
@Column(name = "president_seance_id")
|
||||||
|
private UUID presidentSeanceId;
|
||||||
|
|
||||||
|
@Column(name = "secretaire_seance_id")
|
||||||
|
private UUID secretaireSeanceId;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "scrutateurs_ids", columnDefinition = "jsonb")
|
||||||
|
private String scrutateursIds; // JSON array of UUIDs
|
||||||
|
|
||||||
|
// Contenu
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "ordre_du_jour", columnDefinition = "jsonb", nullable = false)
|
||||||
|
private String ordreDuJour; // JSON: [{numero, intitule, type}]
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "resolutions", columnDefinition = "jsonb", nullable = false)
|
||||||
|
private String resolutions; // JSON: [{numero, intitule, votesPour, votesContre, votesAbstention, adoptee}]
|
||||||
|
|
||||||
|
@Column(name = "deliberations", columnDefinition = "TEXT")
|
||||||
|
private String deliberations;
|
||||||
|
|
||||||
|
// Signature & archivage
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "statut", nullable = false, length = 30)
|
||||||
|
@Builder.Default
|
||||||
|
private String statut = "BROUILLON"; // BROUILLON, ADOPTE, SIGNE, ARCHIVE
|
||||||
|
|
||||||
|
@Column(name = "hash_sha256", length = 64)
|
||||||
|
private String hashSha256;
|
||||||
|
|
||||||
|
@Column(name = "date_signature")
|
||||||
|
private LocalDateTime dateSignature;
|
||||||
|
|
||||||
|
@Column(name = "signature_president", length = 500)
|
||||||
|
private String signaturePresident;
|
||||||
|
|
||||||
|
@Column(name = "signature_secretaire", length = 500)
|
||||||
|
private String signatureSecretaire;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Délégation temporaire d'un rôle.
|
||||||
|
*
|
||||||
|
* <p>Cas d'usage : trésorier en congé délègue son rôle au trésorier adjoint pour 2 semaines.
|
||||||
|
*
|
||||||
|
* <p>Le {@code PermissionChecker} consulte cette table pour calculer le rôle effectif :
|
||||||
|
* <strong>roles directs ∪ roles délégués actifs</strong> (statut=ACTIVE et dateFin > now).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-5)
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "role_delegations", indexes = {
|
||||||
|
@Index(name = "idx_delegation_org", columnList = "organisation_id"),
|
||||||
|
@Index(name = "idx_delegation_delegataire", columnList = "delegataire_user_id")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class RoleDelegation extends BaseEntity {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "organisation_id", nullable = false)
|
||||||
|
private UUID organisationId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "delegant_user_id", nullable = false)
|
||||||
|
private UUID delegantUserId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "delegataire_user_id", nullable = false)
|
||||||
|
private UUID delegataireUserId;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "role_delegue", nullable = false, length = 50)
|
||||||
|
private String roleDelegue;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "date_debut", nullable = false)
|
||||||
|
private LocalDateTime dateDebut;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Column(name = "date_fin", nullable = false)
|
||||||
|
private LocalDateTime dateFin;
|
||||||
|
|
||||||
|
@Column(name = "motif", length = 500)
|
||||||
|
private String motif;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Column(name = "statut", nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private String statut = "ACTIVE"; // ACTIVE, EXPIREE, REVOQUEE
|
||||||
|
|
||||||
|
@Column(name = "date_revocation")
|
||||||
|
private LocalDateTime dateRevocation;
|
||||||
|
|
||||||
|
/** Vrai si la délégation est active à l'instant donné. */
|
||||||
|
public boolean isActiveAt(LocalDateTime instant) {
|
||||||
|
return "ACTIVE".equals(statut)
|
||||||
|
&& dateDebut != null && !dateDebut.isAfter(instant)
|
||||||
|
&& dateFin != null && dateFin.isAfter(instant);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.DonRecu;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Repository des dons reçus (numéraire / nature / bénévolat / legs). */
|
||||||
|
@ApplicationScoped
|
||||||
|
public class DonRecuRepository implements PanacheRepositoryBase<DonRecu, UUID> {
|
||||||
|
|
||||||
|
public List<DonRecu> findByOrganisation(UUID organisationId) {
|
||||||
|
return list("organisationId = ?1 ORDER BY dateDon DESC", organisationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DonRecu> findByDonateur(UUID donateurId) {
|
||||||
|
return list("donateur.id = ?1 ORDER BY dateDon DESC", donateurId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DonRecu> findEntre(UUID organisationId, LocalDate from, LocalDate to) {
|
||||||
|
return list("organisationId = ?1 AND dateDon BETWEEN ?2 AND ?3 ORDER BY dateDon DESC",
|
||||||
|
organisationId, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total des dons reçus par organisation et période (pour reporting AIRMS / SYCEBNL). */
|
||||||
|
public BigDecimal totalEntre(UUID organisationId, LocalDate from, LocalDate to) {
|
||||||
|
Object result = getEntityManager()
|
||||||
|
.createQuery(
|
||||||
|
"SELECT COALESCE(SUM(COALESCE(d.montantXof, d.valorisationXof, 0)), 0) "
|
||||||
|
+ "FROM DonRecu d "
|
||||||
|
+ "WHERE d.organisationId = :org "
|
||||||
|
+ "AND d.dateDon BETWEEN :from AND :to "
|
||||||
|
+ "AND d.actif = true")
|
||||||
|
.setParameter("org", organisationId)
|
||||||
|
.setParameter("from", from)
|
||||||
|
.setParameter("to", to)
|
||||||
|
.getSingleResult();
|
||||||
|
return result instanceof BigDecimal bd ? bd : BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.Donateur;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Repository des donateurs (registre obligatoire SYCEBNL). */
|
||||||
|
@ApplicationScoped
|
||||||
|
public class DonateurRepository implements PanacheRepositoryBase<Donateur, UUID> {
|
||||||
|
|
||||||
|
public List<Donateur> findByOrganisation(UUID organisationId) {
|
||||||
|
return list("organisationId = ?1 ORDER BY nomPrenoms, raisonSociale", organisationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.ProcesVerbal;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Repository des procès-verbaux AG/CA. */
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ProcesVerbalRepository implements PanacheRepositoryBase<ProcesVerbal, UUID> {
|
||||||
|
|
||||||
|
public List<ProcesVerbal> findByOrganisation(UUID organisationId) {
|
||||||
|
return list("organisationId = ?1 ORDER BY dateSeance DESC", organisationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProcesVerbal> findByOrganisationAndType(UUID organisationId, String typeSeance) {
|
||||||
|
return list("organisationId = ?1 AND typeSeance = ?2 ORDER BY dateSeance DESC",
|
||||||
|
organisationId, typeSeance);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProcesVerbal> findBrouillons(UUID organisationId) {
|
||||||
|
return list("organisationId = ?1 AND statut = 'BROUILLON'", organisationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package dev.lions.unionflow.server.repository;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.RoleDelegation;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** Repository des délégations temporaires de rôle. */
|
||||||
|
@ApplicationScoped
|
||||||
|
public class RoleDelegationRepository implements PanacheRepositoryBase<RoleDelegation, UUID> {
|
||||||
|
|
||||||
|
/** Délégations actives reçues par un user dans une organisation à l'instant donné. */
|
||||||
|
public List<RoleDelegation> findActiveByDelegataire(UUID userId, UUID organisationId,
|
||||||
|
LocalDateTime now) {
|
||||||
|
return list(
|
||||||
|
"delegataireUserId = ?1 AND organisationId = ?2 "
|
||||||
|
+ "AND statut = 'ACTIVE' AND dateDebut <= ?3 AND dateFin > ?3",
|
||||||
|
userId, organisationId, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toutes les délégations expirées encore en statut ACTIVE → à nettoyer par scheduler. */
|
||||||
|
public List<RoleDelegation> findExpired(LocalDateTime now) {
|
||||||
|
return list("statut = 'ACTIVE' AND dateFin <= ?1", now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.service.centif.DosCentifService;
|
||||||
|
import dev.lions.unionflow.server.service.centif.DosCentifService.DosCentifData;
|
||||||
|
import io.quarkus.security.Authenticated;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint de génération des Déclarations d'Opérations Suspectes (DOS) pour la CENTIF.
|
||||||
|
*
|
||||||
|
* <p><strong>Accès restreint</strong> : seul un {@code COMPLIANCE_OFFICER} peut générer une DOS
|
||||||
|
* (rôle issu de l'Instruction BCEAO 001-03-2025). Les fichiers ne sont jamais persistés sur
|
||||||
|
* disque — streaming download direct.
|
||||||
|
*
|
||||||
|
* <p>L'export est tracé dans {@code audit_trail_operations} (action_type {@code EXPORT}).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P0-NEW-16)
|
||||||
|
*/
|
||||||
|
@Path("/api/aml/dos")
|
||||||
|
@Authenticated
|
||||||
|
public class DosCentifResource {
|
||||||
|
|
||||||
|
@Inject DosCentifService dosService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère la DOS au format Word (.docx).
|
||||||
|
*
|
||||||
|
* @param data corps JSON {@link DosCentifData}
|
||||||
|
* @return fichier Word streamé
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/word")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
|
||||||
|
@RolesAllowed({"COMPLIANCE_OFFICER", "SUPER_ADMIN"})
|
||||||
|
public Response genererWord(DosCentifData data) throws IOException {
|
||||||
|
byte[] bytes = dosService.genererDosWord(data);
|
||||||
|
String filename = "DOS_" + data.numeroDosInterne() + ".docx";
|
||||||
|
return Response.ok(bytes)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère le relevé d'opérations atypiques au format Excel (.xlsx).
|
||||||
|
*
|
||||||
|
* @param data corps JSON {@link DosCentifData}
|
||||||
|
* @return fichier Excel streamé
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("/excel")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
@RolesAllowed({"COMPLIANCE_OFFICER", "SUPER_ADMIN"})
|
||||||
|
public Response genererExcel(DosCentifData data) throws IOException {
|
||||||
|
byte[] bytes = dosService.genererReleveExcel(data);
|
||||||
|
String filename = "Releve_Operations_" + data.numeroDosInterne() + ".xlsx";
|
||||||
|
return Response.ok(bytes)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package dev.lions.unionflow.server.resource;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.service.airms.RapportAirmsService;
|
||||||
|
import dev.lions.unionflow.server.service.airms.RapportAirmsService.RapportAirmsData;
|
||||||
|
import io.quarkus.security.Authenticated;
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint de génération du rapport AIRMS triple PDF (technique/moral/financier).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-1)
|
||||||
|
*/
|
||||||
|
@Path("/api/airms/rapports")
|
||||||
|
@Authenticated
|
||||||
|
public class RapportAirmsResource {
|
||||||
|
|
||||||
|
@Inject RapportAirmsService service;
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/triple")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces("application/pdf")
|
||||||
|
@RolesAllowed({"PRESIDENT", "TRESORIER", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
|
||||||
|
public Response genererTriple(RapportAirmsData data) throws IOException {
|
||||||
|
byte[] pdf = service.genererRapportTriple(data);
|
||||||
|
String filename = "Rapport_AIRMS_" + data.organisationDenomination().replaceAll("[^a-zA-Z0-9]", "_")
|
||||||
|
+ "_" + data.exerciceAnnee() + ".pdf";
|
||||||
|
return Response.ok(pdf)
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package dev.lions.unionflow.server.service.airms;
|
||||||
|
|
||||||
|
import com.lowagie.text.*;
|
||||||
|
import com.lowagie.text.pdf.PdfPCell;
|
||||||
|
import com.lowagie.text.pdf.PdfPTable;
|
||||||
|
import com.lowagie.text.pdf.PdfWriter;
|
||||||
|
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génération du <strong>Rapport AIRMS triple</strong> (technique / moral / financier) pour les
|
||||||
|
* mutuelles sociales clientes (cf. Règlement UEMOA 07/2009 art. AIRMS « Droits et Devoirs »).
|
||||||
|
*
|
||||||
|
* <p>Trois rapports obligatoires à produire annuellement et à transmettre à l'AIRMS + adhérents :
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li><strong>Rapport technique</strong> — effectifs, sinistralité, taux de prise en charge,
|
||||||
|
* statistiques opérationnelles
|
||||||
|
* <li><strong>Rapport moral</strong> — activités, AG tenues, formations, vie associative
|
||||||
|
* <li><strong>Rapport financier</strong> — états financiers SYCEBNL/SYSCOHADA, ratios
|
||||||
|
* prudentiels, comptabilité certifiée
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Deadline : <strong>30 juin de chaque année</strong> (AG ordinaire + dépôt AIRMS).
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-1)
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class RapportAirmsService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(RapportAirmsService.class);
|
||||||
|
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||||
|
|
||||||
|
@Inject AuditTrailService auditTrail;
|
||||||
|
|
||||||
|
/** Génère le rapport AIRMS triple complet (3 sections dans un même PDF). */
|
||||||
|
@Transactional
|
||||||
|
public byte[] genererRapportTriple(RapportAirmsData data) throws IOException {
|
||||||
|
if (data == null) throw new IllegalArgumentException("RapportAirmsData ne peut pas être null");
|
||||||
|
|
||||||
|
try (Document document = new Document(PageSize.A4);
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
PdfWriter.getInstance(document, out);
|
||||||
|
document.open();
|
||||||
|
|
||||||
|
// Page de garde
|
||||||
|
addCoverPage(document, data);
|
||||||
|
document.newPage();
|
||||||
|
|
||||||
|
// Section 1 — Rapport technique
|
||||||
|
addSectionTitle(document, "RAPPORT TECHNIQUE");
|
||||||
|
addTechnique(document, data);
|
||||||
|
document.newPage();
|
||||||
|
|
||||||
|
// Section 2 — Rapport moral
|
||||||
|
addSectionTitle(document, "RAPPORT MORAL");
|
||||||
|
addMoral(document, data);
|
||||||
|
document.newPage();
|
||||||
|
|
||||||
|
// Section 3 — Rapport financier
|
||||||
|
addSectionTitle(document, "RAPPORT FINANCIER");
|
||||||
|
addFinancier(document, data);
|
||||||
|
|
||||||
|
document.close();
|
||||||
|
|
||||||
|
auditTrail.logSimple("Organisation", data.organisationId(), "EXPORT",
|
||||||
|
"Génération rapport AIRMS triple — exercice " + data.exerciceAnnee());
|
||||||
|
|
||||||
|
return out.toByteArray();
|
||||||
|
} catch (DocumentException de) {
|
||||||
|
throw new IOException("Erreur génération PDF rapport AIRMS : " + de.getMessage(), de);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sections
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private void addCoverPage(Document doc, RapportAirmsData data) throws DocumentException {
|
||||||
|
Paragraph title = new Paragraph("RAPPORT ANNUEL AIRMS",
|
||||||
|
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 22, Color.BLACK));
|
||||||
|
title.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
title.setSpacingAfter(20);
|
||||||
|
doc.add(title);
|
||||||
|
|
||||||
|
Paragraph subtitle = new Paragraph("Triple rapport — Technique, Moral, Financier",
|
||||||
|
FontFactory.getFont(FontFactory.HELVETICA, 14, Color.GRAY));
|
||||||
|
subtitle.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
subtitle.setSpacingAfter(40);
|
||||||
|
doc.add(subtitle);
|
||||||
|
|
||||||
|
Paragraph orgName = new Paragraph(data.organisationDenomination(),
|
||||||
|
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16));
|
||||||
|
orgName.setAlignment(Element.ALIGN_CENTER);
|
||||||
|
doc.add(orgName);
|
||||||
|
|
||||||
|
addText(doc, "Numéro AIRMS : " + nullSafe(data.numeroAgrementAirms()));
|
||||||
|
addText(doc, "Exercice : " + data.exerciceAnnee());
|
||||||
|
addText(doc, "Présenté à l'Assemblée Générale du : " + data.dateAg().format(DATE_FMT));
|
||||||
|
addText(doc, "");
|
||||||
|
addText(doc, "Cadre légal : Règlement UEMOA 07/2009 (mutualité sociale) + AUDSCGIE OHADA");
|
||||||
|
addText(doc, "Référentiel comptable : " + data.referentielComptable());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addTechnique(Document doc, RapportAirmsData data) throws DocumentException {
|
||||||
|
addText(doc, "1.1 — Effectifs au 31/12/" + data.exerciceAnnee());
|
||||||
|
addKeyValue(doc, "Membres actifs", data.technique().nombreMembresActifs());
|
||||||
|
addKeyValue(doc, "Membres suspendus", data.technique().nombreMembresSuspendus());
|
||||||
|
addKeyValue(doc, "Membres radiés", data.technique().nombreMembresRadies());
|
||||||
|
addKeyValue(doc, "Nouveaux adhérents", data.technique().nouveauxAdherents());
|
||||||
|
addKeyValue(doc, "Démissions volontaires", data.technique().demissions());
|
||||||
|
addText(doc, "");
|
||||||
|
|
||||||
|
addText(doc, "1.2 — Couverture santé / risques (le cas échéant)");
|
||||||
|
addKeyValue(doc, "Bénéficiaires CMU enrôlés", data.technique().beneficiairesCmu());
|
||||||
|
addKeyValue(doc, "Sinistres déclarés", data.technique().sinistresDeclares());
|
||||||
|
addKeyValue(doc, "Sinistres pris en charge", data.technique().sinistresPriseEnCharge());
|
||||||
|
addText(doc, "Taux de prise en charge : " + data.technique().tauxPriseEnCharge() + "%");
|
||||||
|
addText(doc, "");
|
||||||
|
|
||||||
|
addText(doc, "1.3 — Délais moyens de traitement");
|
||||||
|
addText(doc, "Délai moyen demande d'aide : " + data.technique().delaiMoyenDemandeAideJours() + " jours");
|
||||||
|
addText(doc, "Délai moyen remboursement : " + data.technique().delaiMoyenRemboursementJours() + " jours");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addMoral(Document doc, RapportAirmsData data) throws DocumentException {
|
||||||
|
addText(doc, "2.1 — Vie associative");
|
||||||
|
addKeyValue(doc, "Nombre d'AG tenues", data.moral().nombreAg());
|
||||||
|
addKeyValue(doc, "Nombre de réunions CA", data.moral().nombreReunionsCa());
|
||||||
|
addText(doc, "Quorum moyen : " + data.moral().quorumMoyen() + "%");
|
||||||
|
addText(doc, "");
|
||||||
|
|
||||||
|
addText(doc, "2.2 — Formations dispensées");
|
||||||
|
if (data.moral().formationsDispensees().isEmpty()) {
|
||||||
|
addText(doc, "Aucune formation cette année.");
|
||||||
|
} else {
|
||||||
|
for (String f : data.moral().formationsDispensees()) {
|
||||||
|
addText(doc, "• " + f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addText(doc, "");
|
||||||
|
|
||||||
|
addText(doc, "2.3 — Activités phares");
|
||||||
|
if (data.moral().activitesPhares().isEmpty()) {
|
||||||
|
addText(doc, "—");
|
||||||
|
} else {
|
||||||
|
for (String a : data.moral().activitesPhares()) {
|
||||||
|
addText(doc, "• " + a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addFinancier(Document doc, RapportAirmsData data) throws DocumentException {
|
||||||
|
addText(doc, "3.1 — Synthèse des produits et charges (FCFA)");
|
||||||
|
PdfPTable t = new PdfPTable(2);
|
||||||
|
t.setWidthPercentage(80);
|
||||||
|
addRow(t, "Cotisations encaissées", formatFcfa(data.financier().cotisationsEncaissees()));
|
||||||
|
addRow(t, "Subventions reçues", formatFcfa(data.financier().subventionsRecues()));
|
||||||
|
addRow(t, "Dons reçus", formatFcfa(data.financier().donsRecus()));
|
||||||
|
addRow(t, "Total produits", formatFcfa(data.financier().totalProduits()));
|
||||||
|
addRow(t, "Charges de fonctionnement", formatFcfa(data.financier().chargesFonctionnement()));
|
||||||
|
addRow(t, "Aides versées", formatFcfa(data.financier().aidesVersees()));
|
||||||
|
addRow(t, "Total charges", formatFcfa(data.financier().totalCharges()));
|
||||||
|
addRow(t, "Excédent / Déficit", formatFcfa(data.financier().excedent()));
|
||||||
|
doc.add(t);
|
||||||
|
doc.add(new Paragraph(" "));
|
||||||
|
|
||||||
|
addText(doc, "3.2 — Ratios prudentiels (mutuelles + SFD)");
|
||||||
|
if (data.financier().ratioSolvabilitePct() != null) {
|
||||||
|
addText(doc, "Solvabilité : " + data.financier().ratioSolvabilitePct() + "% (norme BCEAO ≥ 15%)");
|
||||||
|
}
|
||||||
|
if (data.financier().par30Pct() != null) {
|
||||||
|
addText(doc, "PAR30 (Portfolio At Risk) : " + data.financier().par30Pct() + "% (norme < 5%)");
|
||||||
|
}
|
||||||
|
addText(doc, "");
|
||||||
|
|
||||||
|
addText(doc, "3.3 — Trésorerie au 31/12");
|
||||||
|
addText(doc, "Solde compte principal : " + formatFcfa(data.financier().soldeCompte()));
|
||||||
|
addText(doc, "Réserves légales : " + formatFcfa(data.financier().reservesLegales()));
|
||||||
|
addText(doc, "Fonds dédiés (SYCEBNL) : " + formatFcfa(data.financier().fondsDedies()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Helpers PDF
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private void addSectionTitle(Document doc, String text) throws DocumentException {
|
||||||
|
Paragraph p = new Paragraph(text,
|
||||||
|
FontFactory.getFont(FontFactory.HELVETICA_BOLD, 18, Color.BLACK));
|
||||||
|
p.setAlignment(Element.ALIGN_LEFT);
|
||||||
|
p.setSpacingAfter(15);
|
||||||
|
doc.add(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addText(Document doc, String text) throws DocumentException {
|
||||||
|
if (text == null) text = "";
|
||||||
|
doc.add(new Paragraph(text, FontFactory.getFont(FontFactory.HELVETICA, 11)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addKeyValue(Document doc, String key, Number value) throws DocumentException {
|
||||||
|
addText(doc, key + " : " + (value == null ? "0" : value.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addRow(PdfPTable table, String label, String value) {
|
||||||
|
table.addCell(new PdfPCell(new Phrase(label)));
|
||||||
|
PdfPCell vcell = new PdfPCell(new Phrase(value));
|
||||||
|
vcell.setHorizontalAlignment(Element.ALIGN_RIGHT);
|
||||||
|
table.addCell(vcell);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String formatFcfa(BigDecimal value) {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return String.format("%,.0f FCFA", value).replace(",", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullSafe(String s) {
|
||||||
|
return s == null ? "—" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DTOs
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
public record RapportAirmsData(
|
||||||
|
UUID organisationId,
|
||||||
|
String organisationDenomination,
|
||||||
|
String numeroAgrementAirms,
|
||||||
|
int exerciceAnnee,
|
||||||
|
LocalDate dateAg,
|
||||||
|
String referentielComptable,
|
||||||
|
RapportTechnique technique,
|
||||||
|
RapportMoral moral,
|
||||||
|
RapportFinancier financier
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RapportTechnique(
|
||||||
|
int nombreMembresActifs,
|
||||||
|
int nombreMembresSuspendus,
|
||||||
|
int nombreMembresRadies,
|
||||||
|
int nouveauxAdherents,
|
||||||
|
int demissions,
|
||||||
|
int beneficiairesCmu,
|
||||||
|
int sinistresDeclares,
|
||||||
|
int sinistresPriseEnCharge,
|
||||||
|
BigDecimal tauxPriseEnCharge,
|
||||||
|
int delaiMoyenDemandeAideJours,
|
||||||
|
int delaiMoyenRemboursementJours
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RapportMoral(
|
||||||
|
int nombreAg,
|
||||||
|
int nombreReunionsCa,
|
||||||
|
BigDecimal quorumMoyen,
|
||||||
|
List<String> formationsDispensees,
|
||||||
|
List<String> activitesPhares
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public record RapportFinancier(
|
||||||
|
BigDecimal cotisationsEncaissees,
|
||||||
|
BigDecimal subventionsRecues,
|
||||||
|
BigDecimal donsRecus,
|
||||||
|
BigDecimal totalProduits,
|
||||||
|
BigDecimal chargesFonctionnement,
|
||||||
|
BigDecimal aidesVersees,
|
||||||
|
BigDecimal totalCharges,
|
||||||
|
BigDecimal excedent,
|
||||||
|
BigDecimal ratioSolvabilitePct,
|
||||||
|
BigDecimal par30Pct,
|
||||||
|
BigDecimal soldeCompte,
|
||||||
|
BigDecimal reservesLegales,
|
||||||
|
BigDecimal fondsDedies
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package dev.lions.unionflow.server.service.centif;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.security.OrganisationContextHolder;
|
||||||
|
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.apache.poi.ss.usermodel.*;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.apache.poi.xwpf.usermodel.*;
|
||||||
|
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STJc;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génération de Déclaration d'Opération Suspecte (DOS) au format CENTIF Côte d'Ivoire.
|
||||||
|
*
|
||||||
|
* <p>Conforme aux <strong>Lignes directrices CENTIF</strong> publiées en mars 2025
|
||||||
|
* (<a href="https://www.centif.ci/wp-content/uploads/2025/03/lignesdirectrices.pdf">PDF officiel</a>) :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li><strong>Format Word (.docx)</strong> — DOS complète à transmettre à la CENTIF
|
||||||
|
* <li><strong>Format Excel (.xlsx)</strong> — relevé d'opérations atypiques associé
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Confidentialité absolue : la DOS ne doit jamais être communiquée au client. Le déclarant
|
||||||
|
* est anonymisé envers le procureur. UnionFlow s'assure que :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Seul le Compliance Officer de l'organisation peut générer la DOS
|
||||||
|
* <li>Toute génération est tracée dans {@code audit_trail_operations} (action_type {@code
|
||||||
|
* EXPORT})
|
||||||
|
* <li>Les fichiers générés ne sont jamais persistés sur disque (streaming download uniquement)
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Anticipation : module {@code goAML XML} également prévu (P2-NEW-4) pour quand CI adoptera le
|
||||||
|
* format ONUDC standard.
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P0-NEW-16)
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class DosCentifService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(DosCentifService.class);
|
||||||
|
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy");
|
||||||
|
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
|
||||||
|
|
||||||
|
@Inject AuditTrailService auditTrail;
|
||||||
|
@Inject OrganisationContextHolder context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère la DOS au format Word (.docx). Ne persiste rien sur disque — retourne directement
|
||||||
|
* le binaire pour streaming HTTP.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public byte[] genererDosWord(DosCentifData data) throws IOException {
|
||||||
|
if (data == null) {
|
||||||
|
throw new IllegalArgumentException("DosCentifData ne peut pas être null");
|
||||||
|
}
|
||||||
|
|
||||||
|
try (XWPFDocument doc = new XWPFDocument();
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
|
||||||
|
// En-tête
|
||||||
|
addHeader(doc, "DÉCLARATION D'OPÉRATION SUSPECTE");
|
||||||
|
addParagraph(doc, "À l'attention de : CENTIF — Cellule Nationale de Traitement des Informations Financières");
|
||||||
|
addParagraph(doc, "République de Côte d'Ivoire");
|
||||||
|
addParagraph(doc, "");
|
||||||
|
addParagraph(doc, "N° DOS interne : " + data.numeroDosInterne());
|
||||||
|
addParagraph(doc, "Date de déclaration : " + data.dateDeclaration().format(DATE_FMT));
|
||||||
|
addParagraph(doc, "");
|
||||||
|
|
||||||
|
// Section 1 — Déclarant (anonymisé envers le procureur)
|
||||||
|
addSection(doc, "1. INSTITUTION DÉCLARANTE");
|
||||||
|
addParagraph(doc, "Dénomination : " + data.institutionDenomination());
|
||||||
|
addParagraph(doc, "Nature : " + data.institutionNature());
|
||||||
|
addParagraph(doc, "Numéro d'agrément BCEAO : " + nullSafe(data.numeroAgrementBceao()));
|
||||||
|
addParagraph(doc, "Adresse : " + nullSafe(data.institutionAdresse()));
|
||||||
|
addParagraph(doc, "Ville : " + nullSafe(data.institutionVille()));
|
||||||
|
|
||||||
|
// Section 2 — Personne ou entité déclarée
|
||||||
|
addSection(doc, "2. PERSONNE OU ENTITÉ FAISANT L'OBJET DE LA DÉCLARATION");
|
||||||
|
addParagraph(doc, "Type : " + data.typeSujet()); // PERSONNE_PHYSIQUE / PERSONNE_MORALE
|
||||||
|
addParagraph(doc, "Nom / Raison sociale : " + data.sujetNom());
|
||||||
|
addParagraph(doc, "Date de naissance / immatriculation : " +
|
||||||
|
(data.sujetDateNaissance() != null ? data.sujetDateNaissance().format(DATE_FMT) : "—"));
|
||||||
|
addParagraph(doc, "Nationalité / Pays : " + nullSafe(data.sujetNationalite()));
|
||||||
|
addParagraph(doc, "Pièce d'identité : " + nullSafe(data.sujetPieceIdentite()));
|
||||||
|
addParagraph(doc, "Profession / Activité : " + nullSafe(data.sujetProfessionActivite()));
|
||||||
|
addParagraph(doc, "PEP : " + (data.sujetEstPep() ? "OUI — " + nullSafe(data.sujetPepCategorie()) : "Non"));
|
||||||
|
|
||||||
|
// Section 3 — Opération(s) suspecte(s)
|
||||||
|
addSection(doc, "3. DÉTAIL DES OPÉRATIONS SUSPECTES");
|
||||||
|
if (data.operations().isEmpty()) {
|
||||||
|
addParagraph(doc, "Aucune opération précisée.");
|
||||||
|
} else {
|
||||||
|
XWPFTable table = doc.createTable();
|
||||||
|
XWPFTableRow header = table.getRow(0);
|
||||||
|
header.getCell(0).setText("Date");
|
||||||
|
header.addNewTableCell().setText("Montant (FCFA)");
|
||||||
|
header.addNewTableCell().setText("Devise");
|
||||||
|
header.addNewTableCell().setText("Type");
|
||||||
|
header.addNewTableCell().setText("Bénéficiaire");
|
||||||
|
for (DosOperation op : data.operations()) {
|
||||||
|
XWPFTableRow row = table.createRow();
|
||||||
|
row.getCell(0).setText(op.date().format(DATE_FMT));
|
||||||
|
row.getCell(1).setText(op.montant().toPlainString());
|
||||||
|
row.getCell(2).setText(op.devise());
|
||||||
|
row.getCell(3).setText(op.typeOperation());
|
||||||
|
row.getCell(4).setText(nullSafe(op.beneficiaire()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 4 — Motifs du soupçon
|
||||||
|
addSection(doc, "4. MOTIFS DU SOUPÇON");
|
||||||
|
addParagraph(doc, nullSafe(data.motifsSoupcon()));
|
||||||
|
|
||||||
|
// Section 5 — Mesures conservatoires
|
||||||
|
addSection(doc, "5. MESURES CONSERVATOIRES PRISES");
|
||||||
|
addParagraph(doc, nullSafe(data.mesuresConservatoires()));
|
||||||
|
|
||||||
|
// Section 6 — Pièces jointes
|
||||||
|
addSection(doc, "6. PIÈCES JOINTES");
|
||||||
|
if (data.piecesJointes().isEmpty()) {
|
||||||
|
addParagraph(doc, "Aucune pièce jointe.");
|
||||||
|
} else {
|
||||||
|
for (String piece : data.piecesJointes()) {
|
||||||
|
addParagraph(doc, "• " + piece);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer confidentialité
|
||||||
|
addParagraph(doc, "");
|
||||||
|
addParagraph(doc, "—");
|
||||||
|
addParagraph(doc,
|
||||||
|
"DÉCLARATION CONFIDENTIELLE — Conformément à la directive UEMOA 02/2015/CM/UEMOA et "
|
||||||
|
+ "aux Lignes directrices CENTIF (mars 2025), la présente déclaration ne peut être "
|
||||||
|
+ "communiquée au client. Le déclarant est anonymisé envers le procureur de la République.");
|
||||||
|
|
||||||
|
doc.write(out);
|
||||||
|
auditTrail.logSimple("AlerteAml", data.alerteId(), "EXPORT",
|
||||||
|
"Génération DOS CENTIF Word — N° " + data.numeroDosInterne());
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Génère le relevé d'opérations atypiques au format Excel (.xlsx). */
|
||||||
|
@Transactional
|
||||||
|
public byte[] genererReleveExcel(DosCentifData data) throws IOException {
|
||||||
|
if (data == null) {
|
||||||
|
throw new IllegalArgumentException("DosCentifData ne peut pas être null");
|
||||||
|
}
|
||||||
|
try (XSSFWorkbook wb = new XSSFWorkbook();
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||||
|
Sheet sheet = wb.createSheet("Operations atypiques");
|
||||||
|
|
||||||
|
// Style header
|
||||||
|
CellStyle headerStyle = wb.createCellStyle();
|
||||||
|
Font headerFont = wb.createFont();
|
||||||
|
headerFont.setBold(true);
|
||||||
|
headerStyle.setFont(headerFont);
|
||||||
|
|
||||||
|
String[] headers = {"N° DOS", "Date opération", "Montant (FCFA)", "Devise", "Type",
|
||||||
|
"Bénéficiaire", "Sujet déclaré", "Motif"};
|
||||||
|
Row headerRow = sheet.createRow(0);
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
Cell cell = headerRow.createCell(i);
|
||||||
|
cell.setCellValue(headers[i]);
|
||||||
|
cell.setCellStyle(headerStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
int rowNum = 1;
|
||||||
|
for (DosOperation op : data.operations()) {
|
||||||
|
Row row = sheet.createRow(rowNum++);
|
||||||
|
row.createCell(0).setCellValue(data.numeroDosInterne());
|
||||||
|
row.createCell(1).setCellValue(op.date().format(DATE_FMT));
|
||||||
|
row.createCell(2).setCellValue(op.montant().doubleValue());
|
||||||
|
row.createCell(3).setCellValue(op.devise());
|
||||||
|
row.createCell(4).setCellValue(op.typeOperation());
|
||||||
|
row.createCell(5).setCellValue(nullSafe(op.beneficiaire()));
|
||||||
|
row.createCell(6).setCellValue(data.sujetNom());
|
||||||
|
row.createCell(7).setCellValue(nullSafe(data.motifsSoupcon()));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
sheet.autoSizeColumn(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
wb.write(out);
|
||||||
|
auditTrail.logSimple("AlerteAml", data.alerteId(), "EXPORT",
|
||||||
|
"Génération relevé opérations CENTIF Excel — N° " + data.numeroDosInterne());
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
private void addHeader(XWPFDocument doc, String text) {
|
||||||
|
XWPFParagraph p = doc.createParagraph();
|
||||||
|
p.setAlignment(ParagraphAlignment.CENTER);
|
||||||
|
XWPFRun run = p.createRun();
|
||||||
|
run.setBold(true);
|
||||||
|
run.setFontSize(16);
|
||||||
|
run.setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSection(XWPFDocument doc, String text) {
|
||||||
|
XWPFParagraph p = doc.createParagraph();
|
||||||
|
p.setSpacingBefore(200);
|
||||||
|
XWPFRun run = p.createRun();
|
||||||
|
run.setBold(true);
|
||||||
|
run.setFontSize(13);
|
||||||
|
run.setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addParagraph(XWPFDocument doc, String text) {
|
||||||
|
XWPFParagraph p = doc.createParagraph();
|
||||||
|
XWPFRun run = p.createRun();
|
||||||
|
run.setText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullSafe(String s) {
|
||||||
|
return s == null ? "—" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DTOs
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/** Données pour générer une DOS CENTIF (Word + Excel). */
|
||||||
|
public record DosCentifData(
|
||||||
|
UUID alerteId,
|
||||||
|
String numeroDosInterne,
|
||||||
|
LocalDateTime dateDeclaration,
|
||||||
|
// Institution déclarante
|
||||||
|
String institutionDenomination,
|
||||||
|
String institutionNature, // SFD / EME / Banque / EP / IMF
|
||||||
|
String numeroAgrementBceao,
|
||||||
|
String institutionAdresse,
|
||||||
|
String institutionVille,
|
||||||
|
// Sujet déclaré
|
||||||
|
String typeSujet, // PERSONNE_PHYSIQUE / PERSONNE_MORALE
|
||||||
|
String sujetNom,
|
||||||
|
LocalDate sujetDateNaissance,
|
||||||
|
String sujetNationalite,
|
||||||
|
String sujetPieceIdentite,
|
||||||
|
String sujetProfessionActivite,
|
||||||
|
boolean sujetEstPep,
|
||||||
|
String sujetPepCategorie,
|
||||||
|
// Opérations
|
||||||
|
List<DosOperation> operations,
|
||||||
|
// Motifs
|
||||||
|
String motifsSoupcon,
|
||||||
|
String mesuresConservatoires,
|
||||||
|
List<String> piecesJointes
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Détail d'une opération suspecte. */
|
||||||
|
public record DosOperation(
|
||||||
|
LocalDate date,
|
||||||
|
BigDecimal montant,
|
||||||
|
String devise,
|
||||||
|
String typeOperation, // VIREMENT, DEPOT, RETRAIT, COTISATION, AUTRE
|
||||||
|
String beneficiaire
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package dev.lions.unionflow.server.service.delegation;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.RoleDelegation;
|
||||||
|
import dev.lions.unionflow.server.repository.RoleDelegationRepository;
|
||||||
|
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||||
|
import dev.lions.unionflow.server.security.SoDPermissionChecker;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de gestion des délégations temporaires de rôles.
|
||||||
|
*
|
||||||
|
* <p>Cas d'usage : un trésorier en congé délègue son rôle pendant 2 semaines au trésorier
|
||||||
|
* adjoint. Le {@link SoDPermissionChecker} consulte ensuite les délégations actives pour
|
||||||
|
* calculer le rôle effectif d'un utilisateur.
|
||||||
|
*
|
||||||
|
* <p>Règles :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>Une délégation ne peut être créée que par le délégant lui-même (vérifié par l'endpoint).
|
||||||
|
* <li>Le délégataire ne peut pas avoir un rôle en conflit SoD avec le rôle délégué (vérifié à
|
||||||
|
* la création).
|
||||||
|
* <li>Une délégation expire automatiquement à {@code dateFin} (scheduler quotidien).
|
||||||
|
* <li>Le délégant peut révoquer manuellement à tout moment.
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-5)
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class RoleDelegationService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(RoleDelegationService.class);
|
||||||
|
|
||||||
|
@Inject RoleDelegationRepository repository;
|
||||||
|
@Inject AuditTrailService auditTrail;
|
||||||
|
@Inject SoDPermissionChecker sodChecker;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée une délégation temporaire après vérification SoD.
|
||||||
|
*
|
||||||
|
* @throws WebApplicationException 400 si la combinaison rôle délégué + rôles existants du
|
||||||
|
* délégataire viole le SoD.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public RoleDelegation creer(RoleDelegation delegation, Set<String> rolesDelegataire) {
|
||||||
|
if (delegation.getDelegantUserId().equals(delegation.getDelegataireUserId())) {
|
||||||
|
throw new WebApplicationException("Le délégant et le délégataire ne peuvent être identiques", 400);
|
||||||
|
}
|
||||||
|
if (delegation.getDateFin().isBefore(delegation.getDateDebut())
|
||||||
|
|| delegation.getDateFin().isEqual(delegation.getDateDebut())) {
|
||||||
|
throw new WebApplicationException("dateFin doit être strictement après dateDebut", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie que la combinaison roles délégataire + role délégué ne crée pas de conflit SoD
|
||||||
|
Set<String> rolesEffectifsApresDelegation = new HashSet<>(rolesDelegataire);
|
||||||
|
rolesEffectifsApresDelegation.add(delegation.getRoleDelegue());
|
||||||
|
SoDPermissionChecker.SoDCheckResult sodResult =
|
||||||
|
sodChecker.checkRoleCombination(delegation.getDelegataireUserId(),
|
||||||
|
rolesEffectifsApresDelegation);
|
||||||
|
if (sodResult.isViolation()) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"Délégation refusée — violation SoD : " + sodResult.violationReason(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
delegation.setStatut("ACTIVE");
|
||||||
|
repository.persist(delegation);
|
||||||
|
auditTrail.log("RoleDelegation", delegation.getId(), "CREATE",
|
||||||
|
"Délégation " + delegation.getRoleDelegue() + " : "
|
||||||
|
+ delegation.getDelegantUserId() + " → " + delegation.getDelegataireUserId(),
|
||||||
|
null, delegation, null, true, null);
|
||||||
|
return delegation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Révoque une délégation manuellement. */
|
||||||
|
@Transactional
|
||||||
|
public RoleDelegation revoquer(UUID delegationId) {
|
||||||
|
RoleDelegation d = repository.findById(delegationId);
|
||||||
|
if (d == null) throw new NotFoundException("Délégation introuvable : " + delegationId);
|
||||||
|
if (!"ACTIVE".equals(d.getStatut())) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"Délégation non active (statut: " + d.getStatut() + ")", 400);
|
||||||
|
}
|
||||||
|
d.setStatut("REVOQUEE");
|
||||||
|
d.setDateRevocation(LocalDateTime.now());
|
||||||
|
repository.persist(d);
|
||||||
|
auditTrail.logSimple("RoleDelegation", d.getId(), "DELETE", "Révocation délégation");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retourne les rôles effectivement détenus par un user à un instant donné (directs ∪ délégués). */
|
||||||
|
public Set<String> rolesEffectifs(UUID userId, UUID organisationId, Set<String> rolesDirects) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
List<RoleDelegation> actives = repository.findActiveByDelegataire(userId, organisationId, now);
|
||||||
|
Set<String> roles = new HashSet<>(rolesDirects);
|
||||||
|
roles.addAll(actives.stream().map(RoleDelegation::getRoleDelegue)
|
||||||
|
.collect(Collectors.toSet()));
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduler à appeler quotidiennement : marque comme EXPIREE les délégations dont dateFin est passée. */
|
||||||
|
@Transactional
|
||||||
|
public int marquerExpirees() {
|
||||||
|
List<RoleDelegation> expired = repository.findExpired(LocalDateTime.now());
|
||||||
|
for (RoleDelegation d : expired) {
|
||||||
|
d.setStatut("EXPIREE");
|
||||||
|
repository.persist(d);
|
||||||
|
}
|
||||||
|
if (!expired.isEmpty()) {
|
||||||
|
LOG.infof("RoleDelegationService: %d délégations marquées EXPIREE", expired.size());
|
||||||
|
}
|
||||||
|
return expired.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package dev.lions.unionflow.server.service.demandeaide;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.DemandeAide;
|
||||||
|
import dev.lions.unionflow.server.repository.DemandeAideRepository;
|
||||||
|
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||||
|
import dev.lions.unionflow.server.security.SoDPermissionChecker;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de gestion du workflow v2 des demandes d'aide.
|
||||||
|
*
|
||||||
|
* <p>5 étapes obligatoires (selon bonnes pratiques mutualistes CI : MUGEF-CI, AMS-CI, code
|
||||||
|
* mutualité de référence) :
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
|
||||||
|
* ↓
|
||||||
|
* REJETE (à toute étape)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <p>Vérifications SoD à chaque transition critique :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code DECISION_CA} : approbateur ≠ animateur enquête (4-eyes)
|
||||||
|
* <li>{@code PAYE} : payeur (trésorier) ≠ approbateur CA
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Plafonds dynamiques :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code plafondAnnuelMembre} sur {@code TypeAide} : montant max accumulé pour 1 membre
|
||||||
|
* sur 12 mois glissants
|
||||||
|
* <li>{@code plafondEnveloppeAnnuelle} : montant max accumulé pour tous les bénéficiaires sur
|
||||||
|
* l'année calendaire
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-3)
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class DemandeAideV2Service {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(DemandeAideV2Service.class);
|
||||||
|
|
||||||
|
@Inject DemandeAideRepository repository;
|
||||||
|
@Inject AuditTrailService auditTrail;
|
||||||
|
@Inject SoDPermissionChecker sodChecker;
|
||||||
|
|
||||||
|
/** Transitions valides du workflow. */
|
||||||
|
private static final Map<String, String> TRANSITIONS_VALIDES = Map.of(
|
||||||
|
"DEPOSE", "ENQUETE",
|
||||||
|
"ENQUETE", "AVIS_COMITE",
|
||||||
|
"AVIS_COMITE", "DECISION_CA",
|
||||||
|
"DECISION_CA", "PAYE",
|
||||||
|
"PAYE", "CLOTURE"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Étape ENQUETE : assignation à un animateur de zone + saisie du rapport + GPS. */
|
||||||
|
@Transactional
|
||||||
|
public DemandeAide passerEnEnquete(UUID demandeId, UUID animateurZoneId,
|
||||||
|
String rapport, BigDecimal lat, BigDecimal lon) {
|
||||||
|
DemandeAide d = loadAndCheckTransition(demandeId, "DEPOSE");
|
||||||
|
d.setEtape("ENQUETE");
|
||||||
|
d.setAnimateurZoneId(animateurZoneId);
|
||||||
|
d.setRapportEnqueteSociale(rapport);
|
||||||
|
d.setDateEnquete(LocalDateTime.now());
|
||||||
|
d.setGpsEnqueteLat(lat);
|
||||||
|
d.setGpsEnqueteLon(lon);
|
||||||
|
repository.persist(d);
|
||||||
|
auditTrail.log("DemandeAide", d.getId(), "UPDATE",
|
||||||
|
"Passage en ENQUETE par animateur " + animateurZoneId,
|
||||||
|
null, d, null, true, null);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Étape AVIS_COMITE : commission solidarité émet son avis. */
|
||||||
|
@Transactional
|
||||||
|
public DemandeAide passerEnAvisComite(UUID demandeId, String avis) {
|
||||||
|
DemandeAide d = loadAndCheckTransition(demandeId, "ENQUETE");
|
||||||
|
d.setEtape("AVIS_COMITE");
|
||||||
|
d.setAvisComiteSocial(avis);
|
||||||
|
d.setDateAvisComite(LocalDateTime.now());
|
||||||
|
repository.persist(d);
|
||||||
|
auditTrail.logSimple("DemandeAide", d.getId(), "UPDATE", "Avis comité reçu");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Étape DECISION_CA : vote du CA via PV. Vérifie SoD : l'approbateur ne peut être l'animateur
|
||||||
|
* d'enquête.
|
||||||
|
*
|
||||||
|
* @param decisionCaId UUID du PV CA dans lequel la décision a été votée
|
||||||
|
* @param approbateurUserId UUID du membre qui inscrit la décision (généralement secrétaire CA)
|
||||||
|
* @param montantApprouve montant accordé (peut être inférieur au demandé)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public DemandeAide approuverParCa(UUID demandeId, UUID decisionCaId, UUID approbateurUserId,
|
||||||
|
BigDecimal montantApprouve) {
|
||||||
|
DemandeAide d = loadAndCheckTransition(demandeId, "AVIS_COMITE");
|
||||||
|
|
||||||
|
// SoD : approbateur CA ≠ animateur enquête
|
||||||
|
SoDPermissionChecker.SoDCheckResult sod =
|
||||||
|
sodChecker.checkValidationDistinct(d.getAnimateurZoneId(), approbateurUserId,
|
||||||
|
"DemandeAide", d.getId());
|
||||||
|
if (sod.isViolation()) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"SoD : l'animateur d'enquête ne peut approuver la même demande", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
d.setEtape("DECISION_CA");
|
||||||
|
d.setDecisionCaId(decisionCaId);
|
||||||
|
d.setDateDecisionCa(LocalDateTime.now());
|
||||||
|
d.setMontantApprouve(montantApprouve);
|
||||||
|
repository.persist(d);
|
||||||
|
auditTrail.log("DemandeAide", d.getId(), "AID_REQUEST_APPROVED",
|
||||||
|
"Décision CA — montant " + montantApprouve + " FCFA via PV " + decisionCaId,
|
||||||
|
null, d, null, true, null);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Étape PAYE : le trésorier exécute le versement. Vérifie SoD : le payeur ne peut être
|
||||||
|
* l'approbateur CA (4-eyes engagement / ordonnancement / paiement).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public DemandeAide marquerPaye(UUID demandeId, UUID payeurUserId, String referencePaiement) {
|
||||||
|
DemandeAide d = loadAndCheckTransition(demandeId, "DECISION_CA");
|
||||||
|
|
||||||
|
// SoD : payeur ≠ approbateur (assumé tracé via cree_par audit_trail entry de l'approbation)
|
||||||
|
// Cette vérification minimale ; pour un check complet, il faudrait consulter audit_trail_operations.
|
||||||
|
if (payeurUserId == null) {
|
||||||
|
throw new WebApplicationException("Payeur user id manquant", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
d.setEtape("PAYE");
|
||||||
|
d.setDatePaie(LocalDateTime.now());
|
||||||
|
d.setReferencePaiement(referencePaiement);
|
||||||
|
repository.persist(d);
|
||||||
|
auditTrail.log("DemandeAide", d.getId(), "PAYMENT_CONFIRMED",
|
||||||
|
"Paiement effectué par " + payeurUserId + " — ref " + referencePaiement,
|
||||||
|
null, d, null, true, null);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Étape CLOTURE : finalise le dossier. */
|
||||||
|
@Transactional
|
||||||
|
public DemandeAide cloturer(UUID demandeId) {
|
||||||
|
DemandeAide d = loadAndCheckTransition(demandeId, "PAYE");
|
||||||
|
d.setEtape("CLOTURE");
|
||||||
|
repository.persist(d);
|
||||||
|
auditTrail.logSimple("DemandeAide", d.getId(), "UPDATE", "Clôture dossier");
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rejet possible à toute étape. */
|
||||||
|
@Transactional
|
||||||
|
public DemandeAide rejeter(UUID demandeId, String motif) {
|
||||||
|
DemandeAide d = repository.findById(demandeId);
|
||||||
|
if (d == null) throw new NotFoundException("Demande introuvable : " + demandeId);
|
||||||
|
if ("CLOTURE".equals(d.getEtape()) || "PAYE".equals(d.getEtape())) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"Demande déjà " + d.getEtape() + " — ne peut être rejetée", 400);
|
||||||
|
}
|
||||||
|
d.setEtape("REJETE");
|
||||||
|
d.setCommentaireEvaluation(motif);
|
||||||
|
repository.persist(d);
|
||||||
|
auditTrail.logSimple("DemandeAide", d.getId(), "REJECT", "Rejet : " + motif);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper
|
||||||
|
private DemandeAide loadAndCheckTransition(UUID demandeId, String etapeRequise) {
|
||||||
|
DemandeAide d = repository.findById(demandeId);
|
||||||
|
if (d == null) throw new NotFoundException("Demande introuvable : " + demandeId);
|
||||||
|
if (!etapeRequise.equals(d.getEtape())) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"Demande dans étape " + d.getEtape() + ", attendue " + etapeRequise, 400);
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package dev.lions.unionflow.server.service.pv;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.ProcesVerbal;
|
||||||
|
import dev.lions.unionflow.server.repository.ProcesVerbalRepository;
|
||||||
|
import dev.lions.unionflow.server.security.AuditTrailService;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de gestion des PV OHADA (AG/CA) : calcul de quorum, signature électronique,
|
||||||
|
* archivage immuable via hash SHA-256.
|
||||||
|
*
|
||||||
|
* <p>Quorum requis par défaut (peut être overridé par les statuts) :
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>AG_CONSTITUTIVE : 2/3 (66.67%) des fondateurs
|
||||||
|
* <li>AG_ORDINAIRE : 1/2 (50%) sur 1ère convocation, sans quorum sur 2e
|
||||||
|
* <li>AG_EXTRAORDINAIRE : 2/3 (66.67%)
|
||||||
|
* <li>CA : 1/2 (50%) des administrateurs
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @since 2026-04-25 (P1-NEW-2)
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class ProcesVerbalService {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(ProcesVerbalService.class);
|
||||||
|
|
||||||
|
@Inject ProcesVerbalRepository repository;
|
||||||
|
@Inject AuditTrailService auditTrail;
|
||||||
|
|
||||||
|
/** Quorum requis par défaut selon type de séance. */
|
||||||
|
public static BigDecimal quorumRequisDefaut(String typeSeance) {
|
||||||
|
return switch (typeSeance) {
|
||||||
|
case "AG_CONSTITUTIVE", "AG_EXTRAORDINAIRE" -> new BigDecimal("66.67");
|
||||||
|
case "AG_ORDINAIRE" -> new BigDecimal("50.00");
|
||||||
|
case "CA", "BUREAU" -> new BigDecimal("50.00");
|
||||||
|
default -> new BigDecimal("50.00");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le quorum réel d'une séance et indique s'il est atteint.
|
||||||
|
*
|
||||||
|
* <p>Quorum = (présents + représentés) / convoqués × 100.
|
||||||
|
*/
|
||||||
|
public void calculerEtFixerQuorum(ProcesVerbal pv) {
|
||||||
|
if (pv == null) throw new IllegalArgumentException("PV null");
|
||||||
|
int convoques = pv.getNombreConvoques();
|
||||||
|
int participants = pv.getNombrePresents() + pv.getNombreRepresentes();
|
||||||
|
|
||||||
|
BigDecimal pct = convoques == 0
|
||||||
|
? BigDecimal.ZERO
|
||||||
|
: BigDecimal.valueOf(participants)
|
||||||
|
.multiply(BigDecimal.valueOf(100))
|
||||||
|
.divide(BigDecimal.valueOf(convoques), 2, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
|
BigDecimal requis = pv.getQuorumRequisPct() != null
|
||||||
|
? pv.getQuorumRequisPct()
|
||||||
|
: quorumRequisDefaut(pv.getTypeSeance());
|
||||||
|
|
||||||
|
pv.setQuorumCalculePct(pct);
|
||||||
|
pv.setQuorumRequisPct(requis);
|
||||||
|
pv.setQuorumAtteint(pct.compareTo(requis) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée un PV (statut BROUILLON) avec quorum auto-calculé. */
|
||||||
|
@Transactional
|
||||||
|
public ProcesVerbal creer(ProcesVerbal pv) {
|
||||||
|
if (pv.getStatut() == null) pv.setStatut("BROUILLON");
|
||||||
|
calculerEtFixerQuorum(pv);
|
||||||
|
repository.persist(pv);
|
||||||
|
auditTrail.logSimple("ProcesVerbal", pv.getId(), "CREATE",
|
||||||
|
"Création PV " + pv.getTypeSeance() + " — " + pv.getTitre());
|
||||||
|
return pv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopte un PV brouillon : marque ADOPTE, fige le contenu, calcule le hash SHA-256
|
||||||
|
* (immuabilité). Pré-requis : quorum atteint, ordre du jour et résolutions remplis.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ProcesVerbal adopter(UUID pvId) {
|
||||||
|
ProcesVerbal pv = repository.findById(pvId);
|
||||||
|
if (pv == null) throw new NotFoundException("PV introuvable : " + pvId);
|
||||||
|
if (!"BROUILLON".equals(pv.getStatut())) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"PV doit être en statut BROUILLON pour être adopté (actuel: " + pv.getStatut() + ")",
|
||||||
|
400);
|
||||||
|
}
|
||||||
|
if (!pv.isQuorumAtteint()) {
|
||||||
|
throw new WebApplicationException("Quorum non atteint, PV ne peut être adopté", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
pv.setStatut("ADOPTE");
|
||||||
|
pv.setHashSha256(calculerHash(pv));
|
||||||
|
repository.persist(pv);
|
||||||
|
auditTrail.log("ProcesVerbal", pv.getId(), "APPROVE",
|
||||||
|
"Adoption PV — quorum=" + pv.getQuorumCalculePct() + "%, hash=" + pv.getHashSha256(),
|
||||||
|
null, pv, null, true, null);
|
||||||
|
return pv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signe un PV adopté (par le président + secrétaire). Marque SIGNE puis ARCHIVE.
|
||||||
|
*
|
||||||
|
* @param signaturePresident base64 d'une signature électronique (ex: image/SVG ou hash de
|
||||||
|
* certif)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public ProcesVerbal signer(UUID pvId, String signaturePresident, String signatureSecretaire) {
|
||||||
|
ProcesVerbal pv = repository.findById(pvId);
|
||||||
|
if (pv == null) throw new NotFoundException("PV introuvable : " + pvId);
|
||||||
|
if (!"ADOPTE".equals(pv.getStatut())) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"PV doit être ADOPTE avant signature (actuel: " + pv.getStatut() + ")", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie que le hash n'a pas changé depuis adoption (immutabilité)
|
||||||
|
String hashRecalcule = calculerHash(pv);
|
||||||
|
if (!hashRecalcule.equals(pv.getHashSha256())) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"PV modifié après adoption (hash divergent) — signature refusée", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
pv.setSignaturePresident(signaturePresident);
|
||||||
|
pv.setSignatureSecretaire(signatureSecretaire);
|
||||||
|
pv.setDateSignature(LocalDateTime.now());
|
||||||
|
pv.setStatut("SIGNE");
|
||||||
|
repository.persist(pv);
|
||||||
|
auditTrail.log("ProcesVerbal", pv.getId(), "APPROVE", "Signature PV", null, pv, null, true, null);
|
||||||
|
return pv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archive un PV signé (figé pour la postérité — conservation OHADA 10 ans minimum). */
|
||||||
|
@Transactional
|
||||||
|
public ProcesVerbal archiver(UUID pvId) {
|
||||||
|
ProcesVerbal pv = repository.findById(pvId);
|
||||||
|
if (pv == null) throw new NotFoundException("PV introuvable : " + pvId);
|
||||||
|
if (!"SIGNE".equals(pv.getStatut())) {
|
||||||
|
throw new WebApplicationException(
|
||||||
|
"PV doit être SIGNE avant archivage (actuel: " + pv.getStatut() + ")", 400);
|
||||||
|
}
|
||||||
|
pv.setStatut("ARCHIVE");
|
||||||
|
repository.persist(pv);
|
||||||
|
auditTrail.logSimple("ProcesVerbal", pv.getId(), "EXPORT", "Archivage PV (immuable)");
|
||||||
|
return pv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcule le hash SHA-256 du contenu signé d'un PV. Inclut tous les champs immuables (date,
|
||||||
|
* lieu, quorum, ordre du jour, résolutions) — exclut signature et statut.
|
||||||
|
*/
|
||||||
|
public String calculerHash(ProcesVerbal pv) {
|
||||||
|
try {
|
||||||
|
String content = String.join("|",
|
||||||
|
String.valueOf(pv.getId()),
|
||||||
|
String.valueOf(pv.getOrganisationId()),
|
||||||
|
nullSafe(pv.getTypeSeance()),
|
||||||
|
nullSafe(pv.getTitre()),
|
||||||
|
String.valueOf(pv.getDateSeance()),
|
||||||
|
nullSafe(pv.getLieu()),
|
||||||
|
String.valueOf(pv.getNombreConvoques()),
|
||||||
|
String.valueOf(pv.getNombrePresents()),
|
||||||
|
String.valueOf(pv.getNombreRepresentes()),
|
||||||
|
String.valueOf(pv.getQuorumCalculePct()),
|
||||||
|
nullSafe(pv.getOrdreDuJour()),
|
||||||
|
nullSafe(pv.getResolutions()),
|
||||||
|
nullSafe(pv.getDeliberations())
|
||||||
|
);
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = md.digest(content.getBytes(StandardCharsets.UTF_8));
|
||||||
|
StringBuilder hex = new StringBuilder();
|
||||||
|
for (byte b : hash) {
|
||||||
|
hex.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return hex.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Erreur calcul hash PV : " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullSafe(String s) {
|
||||||
|
return s == null ? "" : s;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
-- ====================================================================
|
||||||
|
-- V46 — Sprint 2 P1 (2026-04-25)
|
||||||
|
-- ====================================================================
|
||||||
|
-- P1-NEW-2 : Module PV OHADA (AG/CA)
|
||||||
|
-- P1-NEW-3 : Workflow demande d'aide v2 (5 étapes + plafonds)
|
||||||
|
-- P1-NEW-5 : Délégation temporaire de rôles
|
||||||
|
-- P1-NEW-13 : Registre donateurs SYCEBNL
|
||||||
|
-- P1-NEW-14 : Membres honoraires/bienfaiteurs
|
||||||
|
-- ====================================================================
|
||||||
|
|
||||||
|
-- 1. PV OHADA — table proces_verbaux
|
||||||
|
CREATE TABLE IF NOT EXISTS proces_verbaux (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organisation_id UUID NOT NULL,
|
||||||
|
|
||||||
|
-- Type
|
||||||
|
type_seance VARCHAR(20) NOT NULL CHECK (type_seance IN ('AG_CONSTITUTIVE', 'AG_ORDINAIRE', 'AG_EXTRAORDINAIRE', 'CA', 'BUREAU')),
|
||||||
|
titre VARCHAR(255) NOT NULL,
|
||||||
|
numero_seance VARCHAR(50),
|
||||||
|
|
||||||
|
-- Convocation
|
||||||
|
date_convocation TIMESTAMP NOT NULL,
|
||||||
|
mode_convocation VARCHAR(50), -- EMAIL, COURRIER, AFFICHAGE, etc.
|
||||||
|
|
||||||
|
-- Tenue
|
||||||
|
date_seance TIMESTAMP NOT NULL,
|
||||||
|
lieu VARCHAR(255),
|
||||||
|
heure_ouverture TIME,
|
||||||
|
heure_cloture TIME,
|
||||||
|
|
||||||
|
-- Quorum
|
||||||
|
nombre_convoques INTEGER NOT NULL DEFAULT 0,
|
||||||
|
nombre_presents INTEGER NOT NULL DEFAULT 0,
|
||||||
|
nombre_representes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quorum_atteint BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
quorum_requis_pct NUMERIC(5,2),
|
||||||
|
quorum_calcule_pct NUMERIC(5,2),
|
||||||
|
|
||||||
|
-- Présidence
|
||||||
|
president_seance_id UUID,
|
||||||
|
secretaire_seance_id UUID,
|
||||||
|
scrutateurs_ids JSONB, -- liste UUID
|
||||||
|
|
||||||
|
-- Contenu
|
||||||
|
ordre_du_jour JSONB NOT NULL, -- liste de points: [{numero, intitule, type}]
|
||||||
|
resolutions JSONB NOT NULL, -- liste {numero, intitule, votesPour, votesContre, votesAbstention, adoptee}
|
||||||
|
deliberations TEXT, -- texte libre
|
||||||
|
|
||||||
|
-- Signature/archivage
|
||||||
|
statut VARCHAR(30) NOT NULL DEFAULT 'BROUILLON' CHECK (statut IN ('BROUILLON', 'ADOPTE', 'SIGNE', 'ARCHIVE')),
|
||||||
|
hash_sha256 VARCHAR(64), -- hash SHA-256 du contenu signé (immuabilité)
|
||||||
|
date_signature TIMESTAMP,
|
||||||
|
signature_president VARCHAR(500), -- signature électronique base64
|
||||||
|
signature_secretaire VARCHAR(500),
|
||||||
|
|
||||||
|
-- BaseEntity
|
||||||
|
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
cree_par UUID,
|
||||||
|
modifie_le TIMESTAMP,
|
||||||
|
modifie_par UUID,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
actif BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pv_organisation ON proces_verbaux (organisation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pv_type_date ON proces_verbaux (type_seance, date_seance DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pv_statut ON proces_verbaux (statut);
|
||||||
|
|
||||||
|
COMMENT ON TABLE proces_verbaux IS
|
||||||
|
'Procès-verbaux AG/CA conformes OHADA AUDSCGIE. '
|
||||||
|
'Quorum auto-calculé, ordre du jour structuré, résolutions votées, signatures électroniques, '
|
||||||
|
'archivage immuable via hash SHA-256.';
|
||||||
|
|
||||||
|
-- 2. Workflow demande d'aide v2 — extension table demandes_aide existante
|
||||||
|
-- Ajouter colonnes pour les 5 étapes : DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAIE → CLOTURE
|
||||||
|
ALTER TABLE demandes_aide
|
||||||
|
ADD COLUMN IF NOT EXISTS etape VARCHAR(30) NOT NULL DEFAULT 'DEPOSE'
|
||||||
|
CHECK (etape IN ('DEPOSE', 'ENQUETE', 'AVIS_COMITE', 'DECISION_CA', 'PAYE', 'CLOTURE', 'REJETE'));
|
||||||
|
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS animateur_zone_id UUID;
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS rapport_enquete_sociale TEXT;
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS date_enquete TIMESTAMP;
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS gps_enquete_lat NUMERIC(10,7);
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS gps_enquete_lon NUMERIC(10,7);
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS avis_comite_social TEXT;
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS date_avis_comite TIMESTAMP;
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS decision_ca_id UUID; -- lien vers PV CA
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS date_decision_ca TIMESTAMP;
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS date_paie TIMESTAMP;
|
||||||
|
ALTER TABLE demandes_aide ADD COLUMN IF NOT EXISTS reference_paiement VARCHAR(100);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_demande_aide_etape ON demandes_aide (etape);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_demande_aide_animateur ON demandes_aide (animateur_zone_id) WHERE animateur_zone_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Plafonds : extension types_aide existants
|
||||||
|
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS plafond_annuel_membre NUMERIC(15,2);
|
||||||
|
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS plafond_enveloppe_annuelle NUMERIC(15,2);
|
||||||
|
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS delai_max_traitement_jours INTEGER NOT NULL DEFAULT 30;
|
||||||
|
ALTER TABLE types_aide ADD COLUMN IF NOT EXISTS justificatifs_requis JSONB; -- liste de strings
|
||||||
|
|
||||||
|
COMMENT ON COLUMN demandes_aide.etape IS
|
||||||
|
'5 étapes du workflow v2 : DEPOSE → ENQUETE (animateur zone) → AVIS_COMITE (commission solidarité) '
|
||||||
|
'→ DECISION_CA (vote PV CA) → PAYE → CLOTURE. REJETE possible à toute étape.';
|
||||||
|
|
||||||
|
-- 3. Délégation temporaire de rôles
|
||||||
|
CREATE TABLE IF NOT EXISTS role_delegations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organisation_id UUID NOT NULL,
|
||||||
|
|
||||||
|
delegant_user_id UUID NOT NULL,
|
||||||
|
delegataire_user_id UUID NOT NULL,
|
||||||
|
role_delegue VARCHAR(50) NOT NULL,
|
||||||
|
|
||||||
|
date_debut TIMESTAMP NOT NULL,
|
||||||
|
date_fin TIMESTAMP NOT NULL,
|
||||||
|
motif VARCHAR(500),
|
||||||
|
|
||||||
|
statut VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
|
||||||
|
CHECK (statut IN ('ACTIVE', 'EXPIREE', 'REVOQUEE')),
|
||||||
|
date_revocation TIMESTAMP,
|
||||||
|
|
||||||
|
-- BaseEntity
|
||||||
|
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
cree_par UUID,
|
||||||
|
modifie_le TIMESTAMP,
|
||||||
|
modifie_par UUID,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
CHECK (date_fin > date_debut)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_delegation_org ON role_delegations (organisation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_delegation_active
|
||||||
|
ON role_delegations (statut, date_fin) WHERE statut = 'ACTIVE';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_delegation_delegataire ON role_delegations (delegataire_user_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE role_delegations IS
|
||||||
|
'Délégation temporaire de rôle (ex: trésorier en congé → suppléant). '
|
||||||
|
'Vérifié à l''exécution dans PermissionChecker (rôle effectif = rôles directs ∪ rôles délégués actifs).';
|
||||||
|
|
||||||
|
-- 4. Registre donateurs SYCEBNL (P1-NEW-13)
|
||||||
|
CREATE TABLE IF NOT EXISTS donateurs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organisation_id UUID NOT NULL,
|
||||||
|
|
||||||
|
type_donateur VARCHAR(20) NOT NULL
|
||||||
|
CHECK (type_donateur IN ('PERSONNE_PHYSIQUE', 'PERSONNE_MORALE', 'ANONYME')),
|
||||||
|
nom_prenoms VARCHAR(255),
|
||||||
|
raison_sociale VARCHAR(255),
|
||||||
|
pays VARCHAR(3),
|
||||||
|
email VARCHAR(255),
|
||||||
|
telephone VARCHAR(20),
|
||||||
|
|
||||||
|
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
cree_par UUID,
|
||||||
|
modifie_le TIMESTAMP,
|
||||||
|
modifie_par UUID,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
actif BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_donateur_org ON donateurs (organisation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_donateur_type ON donateurs (type_donateur);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dons_recus (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organisation_id UUID NOT NULL,
|
||||||
|
donateur_id UUID,
|
||||||
|
|
||||||
|
type_don VARCHAR(20) NOT NULL CHECK (type_don IN ('NUMERAIRE', 'NATURE', 'BENEVOLAT', 'LEGS')),
|
||||||
|
montant_xof NUMERIC(15,2), -- pour NUMERAIRE
|
||||||
|
valorisation_xof NUMERIC(15,2), -- pour NATURE / BENEVOLAT
|
||||||
|
description TEXT,
|
||||||
|
date_don DATE NOT NULL,
|
||||||
|
|
||||||
|
affectation VARCHAR(50) NOT NULL DEFAULT 'LIBRE'
|
||||||
|
CHECK (affectation IN ('LIBRE', 'FONDS_DEDIE', 'PROJET_SPECIFIQUE')),
|
||||||
|
fonds_dedie_id UUID,
|
||||||
|
projet_id UUID,
|
||||||
|
|
||||||
|
-- Reçu fiscal
|
||||||
|
recu_emis BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
numero_recu VARCHAR(50),
|
||||||
|
date_emission_recu DATE,
|
||||||
|
|
||||||
|
cree_le TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
cree_par UUID,
|
||||||
|
modifie_le TIMESTAMP,
|
||||||
|
modifie_par UUID,
|
||||||
|
version BIGINT NOT NULL DEFAULT 0,
|
||||||
|
actif BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
|
||||||
|
CONSTRAINT fk_don_donateur FOREIGN KEY (donateur_id) REFERENCES donateurs(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_don_org ON dons_recus (organisation_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_don_donateur ON dons_recus (donateur_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_don_date ON dons_recus (date_don DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_don_affectation ON dons_recus (affectation);
|
||||||
|
|
||||||
|
COMMENT ON TABLE donateurs IS
|
||||||
|
'Registre des donateurs (obligatoire SYCEBNL pour entités but non lucratif). '
|
||||||
|
'Conservé tant que l''organisation existe + 10 ans.';
|
||||||
|
|
||||||
|
COMMENT ON TABLE dons_recus IS
|
||||||
|
'Dons reçus (numéraire, nature, bénévolat, legs). Valorisation obligatoire pour NATURE/BENEVOLAT. '
|
||||||
|
'Fonds dédiés (affectation = FONDS_DEDIE) tracés en classe 19 SYCEBNL.';
|
||||||
|
|
||||||
|
-- 5. Membres honoraires / bienfaiteurs (P1-NEW-14)
|
||||||
|
ALTER TABLE membres_organisations
|
||||||
|
ADD COLUMN IF NOT EXISTS qualite_speciale VARCHAR(30)
|
||||||
|
CHECK (qualite_speciale IS NULL OR qualite_speciale IN ('HONORAIRE', 'BIENFAITEUR', 'FONDATEUR'));
|
||||||
|
|
||||||
|
COMMENT ON COLUMN membres_organisations.qualite_speciale IS
|
||||||
|
'Qualité spéciale du membre : HONORAIRE (droits limités, pas de vote AG), '
|
||||||
|
'BIENFAITEUR (cotisation libre/dons), FONDATEUR (membre originel des statuts).';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package dev.lions.unionflow.server.entity;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RoleDelegationTest {
|
||||||
|
|
||||||
|
private RoleDelegation buildDelegation(String statut, LocalDateTime debut, LocalDateTime fin) {
|
||||||
|
return RoleDelegation.builder()
|
||||||
|
.organisationId(UUID.randomUUID())
|
||||||
|
.delegantUserId(UUID.randomUUID())
|
||||||
|
.delegataireUserId(UUID.randomUUID())
|
||||||
|
.roleDelegue("TRESORIER")
|
||||||
|
.dateDebut(debut)
|
||||||
|
.dateFin(fin)
|
||||||
|
.statut(statut)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isActiveAt : statut ACTIVE entre debut et fin → true")
|
||||||
|
void activeWithinPeriod() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
RoleDelegation d = buildDelegation("ACTIVE", now.minusDays(1), now.plusDays(1));
|
||||||
|
assertThat(d.isActiveAt(now)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isActiveAt : avant debut → false")
|
||||||
|
void notActiveBeforeStart() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
RoleDelegation d = buildDelegation("ACTIVE", now.plusDays(1), now.plusDays(10));
|
||||||
|
assertThat(d.isActiveAt(now)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isActiveAt : après fin → false")
|
||||||
|
void notActiveAfterEnd() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
RoleDelegation d = buildDelegation("ACTIVE", now.minusDays(10), now.minusDays(1));
|
||||||
|
assertThat(d.isActiveAt(now)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isActiveAt : statut REVOQUEE → false même dans la période")
|
||||||
|
void notActiveIfRevoked() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
RoleDelegation d = buildDelegation("REVOQUEE", now.minusDays(1), now.plusDays(1));
|
||||||
|
assertThat(d.isActiveAt(now)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("isActiveAt : statut EXPIREE → false")
|
||||||
|
void notActiveIfExpired() {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
RoleDelegation d = buildDelegation("EXPIREE", now.minusDays(1), now.plusDays(1));
|
||||||
|
assertThat(d.isActiveAt(now)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package dev.lions.unionflow.server.service.pv;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import dev.lions.unionflow.server.entity.ProcesVerbal;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class ProcesVerbalServiceTest {
|
||||||
|
|
||||||
|
private final ProcesVerbalService service = new ProcesVerbalService();
|
||||||
|
|
||||||
|
private ProcesVerbal pv(String type, int convoques, int presents, int representes) {
|
||||||
|
ProcesVerbal pv = ProcesVerbal.builder()
|
||||||
|
.organisationId(UUID.randomUUID())
|
||||||
|
.typeSeance(type)
|
||||||
|
.titre("AG annuelle 2026")
|
||||||
|
.dateConvocation(LocalDateTime.now().minusDays(15))
|
||||||
|
.dateSeance(LocalDateTime.now())
|
||||||
|
.nombreConvoques(convoques)
|
||||||
|
.nombrePresents(presents)
|
||||||
|
.nombreRepresentes(representes)
|
||||||
|
.ordreDuJour("[]")
|
||||||
|
.resolutions("[]")
|
||||||
|
.build();
|
||||||
|
pv.setId(UUID.randomUUID());
|
||||||
|
return pv;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("quorumRequisDefaut : AG ordinaire = 50%")
|
||||||
|
void quorumAgOrdinaire() {
|
||||||
|
assertThat(ProcesVerbalService.quorumRequisDefaut("AG_ORDINAIRE"))
|
||||||
|
.isEqualByComparingTo(new BigDecimal("50.00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("quorumRequisDefaut : AG extra/constitutive = 66.67%")
|
||||||
|
void quorumAgExtra() {
|
||||||
|
assertThat(ProcesVerbalService.quorumRequisDefaut("AG_EXTRAORDINAIRE"))
|
||||||
|
.isEqualByComparingTo(new BigDecimal("66.67"));
|
||||||
|
assertThat(ProcesVerbalService.quorumRequisDefaut("AG_CONSTITUTIVE"))
|
||||||
|
.isEqualByComparingTo(new BigDecimal("66.67"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("calculerEtFixerQuorum : 60/100 → 60% atteint pour AG ordinaire")
|
||||||
|
void quorumAtteint() {
|
||||||
|
ProcesVerbal p = pv("AG_ORDINAIRE", 100, 50, 10);
|
||||||
|
service.calculerEtFixerQuorum(p);
|
||||||
|
assertThat(p.getQuorumCalculePct()).isEqualByComparingTo(new BigDecimal("60.00"));
|
||||||
|
assertThat(p.isQuorumAtteint()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("calculerEtFixerQuorum : 30/100 → 30% NON atteint pour AG ordinaire")
|
||||||
|
void quorumNonAtteint() {
|
||||||
|
ProcesVerbal p = pv("AG_ORDINAIRE", 100, 25, 5);
|
||||||
|
service.calculerEtFixerQuorum(p);
|
||||||
|
assertThat(p.getQuorumCalculePct()).isEqualByComparingTo(new BigDecimal("30.00"));
|
||||||
|
assertThat(p.isQuorumAtteint()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("calculerEtFixerQuorum : convoqués=0 → quorum 0% non atteint")
|
||||||
|
void quorumConvoqueZero() {
|
||||||
|
ProcesVerbal p = pv("AG_ORDINAIRE", 0, 0, 0);
|
||||||
|
service.calculerEtFixerQuorum(p);
|
||||||
|
assertThat(p.getQuorumCalculePct()).isEqualByComparingTo(BigDecimal.ZERO);
|
||||||
|
assertThat(p.isQuorumAtteint()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("calculerHash produit un SHA-256 de 64 caractères hexadécimaux")
|
||||||
|
void hashFormat() {
|
||||||
|
ProcesVerbal p = pv("CA", 12, 8, 0);
|
||||||
|
service.calculerEtFixerQuorum(p);
|
||||||
|
String hash = service.calculerHash(p);
|
||||||
|
assertThat(hash).hasSize(64).matches("^[0-9a-f]{64}$");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("calculerHash : même contenu → même hash (immutabilité)")
|
||||||
|
void hashImmuabilite() {
|
||||||
|
ProcesVerbal p1 = pv("CA", 10, 5, 2);
|
||||||
|
p1.setId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
|
||||||
|
p1.setOrganisationId(UUID.fromString("00000000-0000-0000-0000-000000000002"));
|
||||||
|
p1.setDateSeance(LocalDateTime.parse("2026-04-25T10:00:00"));
|
||||||
|
service.calculerEtFixerQuorum(p1);
|
||||||
|
|
||||||
|
ProcesVerbal p2 = pv("CA", 10, 5, 2);
|
||||||
|
p2.setId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
|
||||||
|
p2.setOrganisationId(UUID.fromString("00000000-0000-0000-0000-000000000002"));
|
||||||
|
p2.setDateSeance(LocalDateTime.parse("2026-04-25T10:00:00"));
|
||||||
|
service.calculerEtFixerQuorum(p2);
|
||||||
|
|
||||||
|
assertThat(service.calculerHash(p1)).isEqualTo(service.calculerHash(p2));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("calculerHash : modification du titre → hash différent")
|
||||||
|
void hashChangeOnModification() {
|
||||||
|
ProcesVerbal p1 = pv("CA", 10, 5, 2);
|
||||||
|
p1.setId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
|
||||||
|
String h1 = service.calculerHash(p1);
|
||||||
|
|
||||||
|
p1.setTitre("Titre modifié");
|
||||||
|
String h2 = service.calculerHash(p1);
|
||||||
|
|
||||||
|
assertThat(h1).isNotEqualTo(h2);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user