diff --git a/src/main/java/dev/lions/unionflow/server/entity/DemandeAide.java b/src/main/java/dev/lions/unionflow/server/entity/DemandeAide.java index a1acdb8..305ae9f 100644 --- a/src/main/java/dev/lions/unionflow/server/entity/DemandeAide.java +++ b/src/main/java/dev/lions/unionflow/server/entity/DemandeAide.java @@ -76,6 +76,53 @@ public class DemandeAide extends BaseEntity { @Column(name = "documents_fournis") 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 protected void onCreate() { super.onCreate(); // Appelle le onCreate de BaseEntity diff --git a/src/main/java/dev/lions/unionflow/server/entity/DonRecu.java b/src/main/java/dev/lions/unionflow/server/entity/DonRecu.java new file mode 100644 index 0000000..b294943 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/DonRecu.java @@ -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 : + * + *
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 : + * + *
Cas d'usage : trésorier en congé délègue son rôle au trésorier adjoint pour 2 semaines. + * + *
Le {@code PermissionChecker} consulte cette table pour calculer le rôle effectif :
+ * roles directs ∪ roles délégués actifs (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);
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/repository/DonRecuRepository.java b/src/main/java/dev/lions/unionflow/server/repository/DonRecuRepository.java
new file mode 100644
index 0000000..1bf7ad1
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/repository/DonRecuRepository.java
@@ -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 Accès restreint : 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.
+ *
+ * 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();
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/resource/RapportAirmsResource.java b/src/main/java/dev/lions/unionflow/server/resource/RapportAirmsResource.java
new file mode 100644
index 0000000..c8e7b80
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/resource/RapportAirmsResource.java
@@ -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();
+ }
+}
diff --git a/src/main/java/dev/lions/unionflow/server/service/airms/RapportAirmsService.java b/src/main/java/dev/lions/unionflow/server/service/airms/RapportAirmsService.java
new file mode 100644
index 0000000..aa7ae29
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/airms/RapportAirmsService.java
@@ -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 Rapport AIRMS triple (technique / moral / financier) pour les
+ * mutuelles sociales clientes (cf. Règlement UEMOA 07/2009 art. AIRMS « Droits et Devoirs »).
+ *
+ * Trois rapports obligatoires à produire annuellement et à transmettre à l'AIRMS + adhérents :
+ *
+ * Deadline : 30 juin de chaque année (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 Conforme aux Lignes directrices CENTIF publiées en mars 2025
+ * (PDF officiel) :
+ *
+ * Confidentialité absolue : la DOS ne doit jamais être communiquée au client. Le déclarant
+ * est anonymisé envers le procureur. UnionFlow s'assure que :
+ *
+ * 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 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.
+ *
+ * Règles :
+ *
+ * 5 étapes obligatoires (selon bonnes pratiques mutualistes CI : MUGEF-CI, AMS-CI, code
+ * mutualité de référence) :
+ *
+ * Vérifications SoD à chaque transition critique :
+ *
+ * Plafonds dynamiques :
+ *
+ * Quorum requis par défaut (peut être overridé par les statuts) :
+ *
+ * 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;
+ }
+}
diff --git a/src/main/resources/db/migration/V46__P1_2026_04_25_PV_OHADA_DemandeAide_v2_Donateurs_RoleDelegation.sql b/src/main/resources/db/migration/V46__P1_2026_04_25_PV_OHADA_DemandeAide_v2_Donateurs_RoleDelegation.sql
new file mode 100644
index 0000000..704ef82
--- /dev/null
+++ b/src/main/resources/db/migration/V46__P1_2026_04_25_PV_OHADA_DemandeAide_v2_Donateurs_RoleDelegation.sql
@@ -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).';
diff --git a/src/test/java/dev/lions/unionflow/server/entity/RoleDelegationTest.java b/src/test/java/dev/lions/unionflow/server/entity/RoleDelegationTest.java
new file mode 100644
index 0000000..6035ac3
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/entity/RoleDelegationTest.java
@@ -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();
+ }
+}
diff --git a/src/test/java/dev/lions/unionflow/server/service/pv/ProcesVerbalServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/pv/ProcesVerbalServiceTest.java
new file mode 100644
index 0000000..b5e282b
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/service/pv/ProcesVerbalServiceTest.java
@@ -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);
+ }
+}
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @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
+ * DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
+ * ↓
+ * REJETE (à toute étape)
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @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
+ *
+ *
+ * @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.
+ *
+ *