diff --git a/src/main/java/dev/lions/unionflow/server/entity/RapportTrimestrielControleurInterne.java b/src/main/java/dev/lions/unionflow/server/entity/RapportTrimestrielControleurInterne.java new file mode 100644 index 0000000..180ee02 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/entity/RapportTrimestrielControleurInterne.java @@ -0,0 +1,97 @@ +package dev.lions.unionflow.server.entity; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +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; + +/** + * Rapport trimestriel agrégé généré par/pour le Contrôleur Interne d'une organisation. + * + *
Source documentaire pour : + *
Cycle de vie : {@code DRAFT} → {@code SIGNE} (hash SHA-256 calculé) → {@code ARCHIVE}.
+ *
+ * @since 2026-04-25 (P2-NEW-3)
+ */
+@Entity
+@Table(name = "rapports_trimestriels_controleur_interne",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uq_rapport_trim_org_annee_trim",
+ columnNames = {"organisation_id", "annee", "trimestre"}),
+ indexes = {
+ @Index(name = "idx_rapport_trim_org", columnList = "organisation_id"),
+ @Index(name = "idx_rapport_trim_annee_trim", columnList = "annee,trimestre"),
+ @Index(name = "idx_rapport_trim_statut", columnList = "statut")
+ })
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@EqualsAndHashCode(callSuper = true)
+public class RapportTrimestrielControleurInterne extends BaseEntity {
+
+ @Column(name = "organisation_id", nullable = false)
+ private UUID organisationId;
+
+ @Min(2024)
+ @Max(2099)
+ @Column(name = "annee", nullable = false)
+ private Integer annee;
+
+ @Min(1)
+ @Max(4)
+ @Column(name = "trimestre", nullable = false)
+ private Integer trimestre;
+
+ @Column(name = "date_generation", nullable = false)
+ private LocalDateTime dateGeneration;
+
+ @Builder.Default
+ @Column(name = "statut", nullable = false, length = 20)
+ private String statut = "DRAFT";
+
+ @Builder.Default
+ @Min(0)
+ @Max(100)
+ @Column(name = "score_conformite", nullable = false)
+ private Integer scoreConformite = 0;
+
+ /** Snapshot agrégé en JSON (compliance score, DOS count, KYC %, etc.). */
+ @JdbcTypeCode(SqlTypes.JSON)
+ @Column(name = "contenu_jsonb", columnDefinition = "jsonb")
+ private JsonNode contenuJsonb;
+
+ /** PDF généré par OpenPDF. Null avant génération. */
+ @Column(name = "pdf_bytes")
+ private byte[] pdfBytes;
+
+ /** UUID du membre Contrôleur Interne signataire. */
+ @Column(name = "signataire_id")
+ private UUID signataireId;
+
+ @Column(name = "date_signature")
+ private LocalDateTime dateSignature;
+
+ /** Hash SHA-256 du contenu_jsonb calculé à la signature — immuable ensuite. */
+ @Column(name = "hash_sha256", length = 64)
+ private String hashSha256;
+}
diff --git a/src/main/java/dev/lions/unionflow/server/repository/RapportTrimestrielControleurInterneRepository.java b/src/main/java/dev/lions/unionflow/server/repository/RapportTrimestrielControleurInterneRepository.java
new file mode 100644
index 0000000..742bd2e
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/repository/RapportTrimestrielControleurInterneRepository.java
@@ -0,0 +1,28 @@
+package dev.lions.unionflow.server.repository;
+
+import dev.lions.unionflow.server.entity.RapportTrimestrielControleurInterne;
+import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase;
+import jakarta.enterprise.context.ApplicationScoped;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/** Repository des rapports trimestriels Contrôleur Interne. */
+@ApplicationScoped
+public class RapportTrimestrielControleurInterneRepository
+ implements PanacheRepositoryBase Accès restreint au {@code CONTROLEUR_INTERNE} de l'organisation et au {@code SUPER_ADMIN}.
+ * La sélection de l'organisation active passe par le filtre {@link OrganisationContextHolder}
+ * (header {@code X-Active-Organisation-Id}) ou via paramètre {@code orgId} pour SUPER_ADMIN.
+ */
+@Path("/api/rapports/trimestriel")
+@Produces(MediaType.APPLICATION_JSON)
+@Authenticated
+public class RapportTrimestrielResource {
+
+ @Inject RapportTrimestrielService service;
+ @Inject RapportTrimestrielControleurInterneRepository repository;
+ @Inject OrganisationContextHolder orgContext;
+
+ @GET
+ @RolesAllowed({"CONTROLEUR_INTERNE", "PRESIDENT", "ADMIN_ORGANISATION", "SUPER_ADMIN"})
+ public List Override possible via configuration : {@code unionflow.reporting.trimestriel.cron}.
+ *
+ * @since 2026-04-25 (P2-NEW-3)
+ */
+@ApplicationScoped
+public class RapportTrimestrielScheduler {
+
+ private static final Logger LOG = Logger.getLogger(RapportTrimestrielScheduler.class);
+
+ @Inject RapportTrimestrielService service;
+ @Inject OrganisationRepository organisationRepository;
+
+ /**
+ * Cron Quarkus 6-fields : sec min hour dayOfMonth month dayOfWeek.
+ * 1er jan/avr/jul/oct à 02:17. Minute non ronde pour étaler la charge sur la flotte.
+ */
+ @Scheduled(
+ cron = "${unionflow.reporting.trimestriel.cron:0 17 2 1 1,4,7,10 ?}",
+ identity = "rapport-trimestriel-controleur-interne",
+ concurrentExecution = Scheduled.ConcurrentExecution.SKIP)
+ @Transactional
+ void genererRapportsTrimestrePrecedent() {
+ Trimestre tp = trimestrePrecedent(LocalDate.now());
+ LOG.infof("[Scheduler] Démarrage génération rapports trimestriels — %d/T%d",
+ tp.annee, tp.trimestre);
+
+ int succes = 0;
+ int erreurs = 0;
+ for (Organisation org : organisationRepository.list("actif = true")) {
+ try {
+ service.genererRapport(org.getId(), tp.annee, tp.trimestre);
+ succes++;
+ } catch (IllegalStateException e) {
+ // Rapport déjà SIGNE/ARCHIVE — normal en cas de relance
+ LOG.debugf("Rapport %d/T%d org=%s déjà finalisé : %s",
+ tp.annee, tp.trimestre, org.getId(), e.getMessage());
+ } catch (Exception e) {
+ erreurs++;
+ LOG.errorf(e, "Échec génération rapport %d/T%d pour org=%s",
+ tp.annee, tp.trimestre, org.getId());
+ }
+ }
+ LOG.infof("[Scheduler] Rapports trimestriels %d/T%d : %d succès, %d erreurs",
+ tp.annee, tp.trimestre, succes, erreurs);
+ }
+
+ /** Calcule (année, trimestre) du trimestre précédent à partir d'une date donnée. */
+ static Trimestre trimestrePrecedent(LocalDate date) {
+ YearMonth ymPrev = YearMonth.from(date).minusMonths(1);
+ int trimestre = (ymPrev.getMonthValue() - 1) / 3 + 1;
+ return new Trimestre(ymPrev.getYear(), trimestre);
+ }
+
+ record Trimestre(int annee, int trimestre) {}
+}
diff --git a/src/main/java/dev/lions/unionflow/server/security/RoleConstant.java b/src/main/java/dev/lions/unionflow/server/security/RoleConstant.java
index e0d6728..7724755 100644
--- a/src/main/java/dev/lions/unionflow/server/security/RoleConstant.java
+++ b/src/main/java/dev/lions/unionflow/server/security/RoleConstant.java
@@ -19,6 +19,9 @@ public final class RoleConstant {
public static final String SECRETAIRE = "SECRETAIRE";
public static final String TRESORIER = "TRESORIER";
public static final String MODERATEUR = "MODERATEUR";
+ public static final String CONTROLEUR_INTERNE = "CONTROLEUR_INTERNE";
+ public static final String COMPLIANCE_OFFICER = "COMPLIANCE_OFFICER";
+ public static final String COMMISSAIRE_COMPTES = "COMMISSAIRE_COMPTES";
// ── Rôles membres ─────────────────────────────────────────────────────────
public static final String MEMBRE = "MEMBRE";
diff --git a/src/main/java/dev/lions/unionflow/server/service/reporting/RapportTrimestrielService.java b/src/main/java/dev/lions/unionflow/server/service/reporting/RapportTrimestrielService.java
new file mode 100644
index 0000000..48604f0
--- /dev/null
+++ b/src/main/java/dev/lions/unionflow/server/service/reporting/RapportTrimestrielService.java
@@ -0,0 +1,436 @@
+package dev.lions.unionflow.server.service.reporting;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.lowagie.text.Document;
+import com.lowagie.text.Element;
+import com.lowagie.text.Font;
+import com.lowagie.text.FontFactory;
+import com.lowagie.text.PageSize;
+import com.lowagie.text.Paragraph;
+import com.lowagie.text.Phrase;
+import com.lowagie.text.pdf.PdfPCell;
+import com.lowagie.text.pdf.PdfPTable;
+import com.lowagie.text.pdf.PdfWriter;
+import dev.lions.unionflow.server.entity.RapportTrimestrielControleurInterne;
+import dev.lions.unionflow.server.repository.AuditTrailOperationRepository;
+import dev.lions.unionflow.server.repository.RapportTrimestrielControleurInterneRepository;
+import dev.lions.unionflow.server.security.AuditTrailService;
+import dev.lions.unionflow.server.service.compliance.ComplianceDashboardService;
+import dev.lions.unionflow.server.service.compliance.ComplianceDashboardService.ComplianceSnapshot;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.persistence.EntityManager;
+import jakarta.transaction.Transactional;
+import java.awt.Color;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HexFormat;
+import java.util.UUID;
+import org.jboss.logging.Logger;
+
+/**
+ * Service de génération automatisée des rapports trimestriels du Contrôleur Interne.
+ *
+ * Pour chaque {@code (organisation, année, trimestre)} :
+ * Cycle de vie : DRAFT → SIGNE (hash SHA-256 figé) → ARCHIVE.
+ *
+ * @since 2026-04-25 (P2-NEW-3)
+ */
+@ApplicationScoped
+public class RapportTrimestrielService {
+
+ private static final Logger LOG = Logger.getLogger(RapportTrimestrielService.class);
+ private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @Inject EntityManager em;
+ @Inject ComplianceDashboardService complianceService;
+ @Inject AuditTrailOperationRepository auditTrailRepo;
+ @Inject RapportTrimestrielControleurInterneRepository repository;
+ @Inject AuditTrailService auditTrail;
+
+ /**
+ * Génère (ou régénère) le rapport trimestriel pour {@code (orgId, annee, trimestre)}.
+ *
+ * @return l'entité persistée avec PDF + JSON snapshot
+ * @throws IllegalStateException si le rapport est déjà SIGNE ou ARCHIVE (pas de réécriture)
+ */
+ @Transactional
+ public RapportTrimestrielControleurInterne genererRapport(UUID orgId, int annee, int trimestre) {
+ if (trimestre < 1 || trimestre > 4) {
+ throw new IllegalArgumentException("Trimestre doit être entre 1 et 4 (reçu: " + trimestre + ")");
+ }
+
+ var existant = repository.trouverParOrgAnneeTrimestre(orgId, annee, trimestre);
+ if (existant.isPresent() && !"DRAFT".equals(existant.get().getStatut())) {
+ throw new IllegalStateException(
+ "Rapport " + annee + "/T" + trimestre + " déjà " + existant.get().getStatut()
+ + " — régénération interdite (immuabilité signature)");
+ }
+
+ DonneesAgregees donnees = agreger(orgId, annee, trimestre);
+ JsonNode contenuJsonb = construireJson(donnees);
+ byte[] pdfBytes = genererPdf(donnees);
+
+ RapportTrimestrielControleurInterne rapport = existant.orElseGet(
+ RapportTrimestrielControleurInterne::new);
+ rapport.setOrganisationId(orgId);
+ rapport.setAnnee(annee);
+ rapport.setTrimestre(trimestre);
+ rapport.setDateGeneration(LocalDateTime.now());
+ rapport.setStatut("DRAFT");
+ rapport.setScoreConformite(donnees.snapshot.scoreGlobal());
+ rapport.setContenuJsonb(contenuJsonb);
+ rapport.setPdfBytes(pdfBytes);
+ rapport.setActif(true);
+
+ repository.persist(rapport);
+
+ auditTrail.logSimple("RapportTrimestrielControleurInterne", rapport.getId(), "EXPORT",
+ "Génération rapport trimestriel " + annee + "/T" + trimestre + " org=" + orgId);
+ LOG.infof("Rapport trimestriel %s/T%s généré pour org=%s (score=%s)",
+ annee, trimestre, orgId, donnees.snapshot.scoreGlobal());
+
+ return rapport;
+ }
+
+ /**
+ * Signe le rapport — calcule hash SHA-256 du contenu JSON, fige l'état.
+ *
+ * @throws IllegalStateException si déjà SIGNE/ARCHIVE
+ */
+ @Transactional
+ public RapportTrimestrielControleurInterne signer(UUID rapportId, UUID signataireId) {
+ RapportTrimestrielControleurInterne rapport = repository.findById(rapportId);
+ if (rapport == null) {
+ throw new IllegalArgumentException("Rapport introuvable : " + rapportId);
+ }
+ if (!"DRAFT".equals(rapport.getStatut())) {
+ throw new IllegalStateException(
+ "Rapport déjà " + rapport.getStatut() + " — signature impossible");
+ }
+
+ String hash = calculerHash(rapport.getContenuJsonb());
+ rapport.setStatut("SIGNE");
+ rapport.setSignataireId(signataireId);
+ rapport.setDateSignature(LocalDateTime.now());
+ rapport.setHashSha256(hash);
+
+ auditTrail.logSimple("RapportTrimestrielControleurInterne", rapportId, "VALIDATE",
+ "Signature contrôleur interne — hash=" + hash.substring(0, 16) + "...");
+ return rapport;
+ }
+
+ /** Archive un rapport SIGNE. */
+ @Transactional
+ public RapportTrimestrielControleurInterne archiver(UUID rapportId) {
+ RapportTrimestrielControleurInterne rapport = repository.findById(rapportId);
+ if (rapport == null) {
+ throw new IllegalArgumentException("Rapport introuvable : " + rapportId);
+ }
+ if (!"SIGNE".equals(rapport.getStatut())) {
+ throw new IllegalStateException(
+ "Rapport en statut " + rapport.getStatut() + " — archivage interdit (signer d'abord)");
+ }
+ rapport.setStatut("ARCHIVE");
+ auditTrail.logSimple("RapportTrimestrielControleurInterne", rapportId, "VALIDATE",
+ "Archivage rapport trimestriel");
+ return rapport;
+ }
+
+ // ============================================================
+ // Agrégation
+ // ============================================================
+
+ DonneesAgregees agreger(UUID orgId, int annee, int trimestre) {
+ LocalDate debut = debutTrimestre(annee, trimestre);
+ LocalDate fin = finTrimestre(annee, trimestre);
+ LocalDateTime debutDt = debut.atStartOfDay();
+ LocalDateTime finDt = fin.plusDays(1).atStartOfDay();
+
+ ComplianceSnapshot snapshot = complianceService.snapshot(orgId);
+
+ long dosCount = compterDosCentif(orgId, debutDt, finDt);
+ long formationsCount = compterFormationsLbcFt(orgId, debut, fin);
+ long anomaliesAuditCount = auditTrailRepo
+ .find("organisationActiveId = ?1 AND operationAt BETWEEN ?2 AND ?3 AND sodCheckPassed = false",
+ orgId, debutDt, finDt)
+ .count();
+ long demandesAideTraitees = compterDemandesAide(orgId, debutDt, finDt);
+
+ return new DonneesAgregees(orgId, annee, trimestre, debut, fin,
+ snapshot, dosCount, formationsCount, anomaliesAuditCount, demandesAideTraitees);
+ }
+
+ private long compterDosCentif(UUID orgId, LocalDateTime debut, LocalDateTime fin) {
+ try {
+ Long c = (Long) em.createQuery(
+ "SELECT COUNT(d) FROM DosCentif d "
+ + "WHERE d.organisationId = :org AND d.dateDeclaration BETWEEN :debut AND :fin")
+ .setParameter("org", orgId)
+ .setParameter("debut", debut)
+ .setParameter("fin", fin)
+ .getSingleResult();
+ return c == null ? 0L : c;
+ } catch (Exception e) {
+ LOG.debugf("Entité DosCentif absente, count=0 (msg=%s)", e.getMessage());
+ return 0L;
+ }
+ }
+
+ private long compterFormationsLbcFt(UUID orgId, LocalDate debut, LocalDate fin) {
+ try {
+ Long c = (Long) em.createQuery(
+ "SELECT COUNT(f) FROM FormationLbcFt f "
+ + "WHERE f.organisationId = :org "
+ + "AND f.dateSession BETWEEN :debut AND :fin")
+ .setParameter("org", orgId)
+ .setParameter("debut", debut.atStartOfDay())
+ .setParameter("fin", fin.plusDays(1).atStartOfDay())
+ .getSingleResult();
+ return c == null ? 0L : c;
+ } catch (Exception e) {
+ return 0L;
+ }
+ }
+
+ private long compterDemandesAide(UUID orgId, LocalDateTime debut, LocalDateTime fin) {
+ try {
+ Long c = (Long) em.createQuery(
+ "SELECT COUNT(d) FROM DemandeAide d "
+ + "WHERE d.organisationId = :org AND d.dateCreation BETWEEN :debut AND :fin")
+ .setParameter("org", orgId)
+ .setParameter("debut", debut)
+ .setParameter("fin", fin)
+ .getSingleResult();
+ return c == null ? 0L : c;
+ } catch (Exception e) {
+ return 0L;
+ }
+ }
+
+ // ============================================================
+ // Sérialisation JSON
+ // ============================================================
+
+ JsonNode construireJson(DonneesAgregees d) {
+ ObjectNode root = MAPPER.createObjectNode();
+ root.put("organisationId", d.orgId.toString());
+ root.put("annee", d.annee);
+ root.put("trimestre", d.trimestre);
+ root.put("periode_debut", d.debut.toString());
+ root.put("periode_fin", d.fin.toString());
+ root.put("date_generation", LocalDateTime.now().toString());
+
+ ObjectNode conformite = root.putObject("conformite");
+ conformite.put("score_global", d.snapshot.scoreGlobal());
+ conformite.put("compliance_officer_designe", d.snapshot.complianceOfficerDesigne());
+ conformite.put("ag_annuelle_statut", d.snapshot.agAnnuelle().statut());
+ conformite.put("rapport_airms_statut", d.snapshot.rapportAirms().statut());
+ conformite.put("dirigeants_avec_cmu", d.snapshot.dirigeantsAvecCmu());
+ conformite.put("taux_kyc_pct", d.snapshot.tauxKycAJourPct());
+ conformite.put("taux_formation_lbcft_pct", d.snapshot.tauxFormationLbcFtPct());
+ conformite.put("couverture_ubo_pct", d.snapshot.couvertureUboPct());
+
+ ObjectNode activite = root.putObject("activite_trimestrielle");
+ activite.put("dos_centif_emises", d.dosCount);
+ activite.put("sessions_formation_lbcft", d.formationsCount);
+ activite.put("anomalies_audit_trail_sod", d.anomaliesAuditCount);
+ activite.put("demandes_aide_creees", d.demandesAideTraitees);
+
+ ArrayNode alertes = root.putArray("alertes");
+ if (!d.snapshot.complianceOfficerDesigne()) {
+ alertes.add("CRITIQUE — Compliance Officer non désigné (Instr. BCEAO 001-03-2025)");
+ }
+ if ("RETARD".equals(d.snapshot.agAnnuelle().statut())) {
+ alertes.add("CRITIQUE — AG annuelle en retard (échéance 30/06)");
+ }
+ if (d.anomaliesAuditCount > 0) {
+ alertes.add("ATTENTION — " + d.anomaliesAuditCount + " violation(s) SoD détectée(s)");
+ }
+ if (d.snapshot.scoreGlobal() < 60) {
+ alertes.add("ATTENTION — Score conformité <60% (" + d.snapshot.scoreGlobal() + ")");
+ }
+
+ return root;
+ }
+
+ // ============================================================
+ // PDF OpenPDF
+ // ============================================================
+
+ byte[] genererPdf(DonneesAgregees d) {
+ try (Document doc = new Document(PageSize.A4);
+ ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ PdfWriter.getInstance(doc, out);
+ doc.open();
+
+ Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, Color.BLACK);
+ Font sectionFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 12, new Color(0, 51, 102));
+ Font normalFont = FontFactory.getFont(FontFactory.HELVETICA, 10, Color.BLACK);
+
+ Paragraph title = new Paragraph(
+ "RAPPORT TRIMESTRIEL — CONTRÔLEUR INTERNE\n" + d.snapshot.organisationNom(), titleFont);
+ title.setAlignment(Element.ALIGN_CENTER);
+ title.setSpacingAfter(20);
+ doc.add(title);
+
+ Paragraph periode = new Paragraph(
+ String.format("Trimestre %d / %d (%s — %s)",
+ d.trimestre, d.annee, d.debut, d.fin), normalFont);
+ periode.setAlignment(Element.ALIGN_CENTER);
+ periode.setSpacingAfter(15);
+ doc.add(periode);
+
+ Paragraph genere = new Paragraph(
+ "Généré le " + LocalDateTime.now().format(DATE_FMT), normalFont);
+ genere.setAlignment(Element.ALIGN_CENTER);
+ genere.setSpacingAfter(25);
+ doc.add(genere);
+
+ // Section conformité
+ doc.add(new Paragraph("1. INDICATEURS DE CONFORMITÉ", sectionFont));
+ PdfPTable tConf = new PdfPTable(2);
+ tConf.setWidthPercentage(100);
+ tConf.setSpacingBefore(5);
+ tConf.setSpacingAfter(15);
+ addRow(tConf, "Score global", d.snapshot.scoreGlobal() + " / 100", normalFont);
+ addRow(tConf, "Compliance Officer", d.snapshot.complianceOfficerDesigne() ? "Désigné" : "ABSENT",
+ normalFont);
+ addRow(tConf, "AG annuelle", d.snapshot.agAnnuelle().statut(), normalFont);
+ addRow(tConf, "Rapport AIRMS", d.snapshot.rapportAirms().statut(), normalFont);
+ addRow(tConf, "Dirigeants avec CMU", String.valueOf(d.snapshot.dirigeantsAvecCmu()), normalFont);
+ addRow(tConf, "Taux KYC à jour", d.snapshot.tauxKycAJourPct() + " %", normalFont);
+ addRow(tConf, "Taux formation LBC/FT", d.snapshot.tauxFormationLbcFtPct() + " %", normalFont);
+ addRow(tConf, "Couverture UBO", d.snapshot.couvertureUboPct() + " %", normalFont);
+ doc.add(tConf);
+
+ // Section activité
+ doc.add(new Paragraph("2. ACTIVITÉ DU TRIMESTRE", sectionFont));
+ PdfPTable tAct = new PdfPTable(2);
+ tAct.setWidthPercentage(100);
+ tAct.setSpacingBefore(5);
+ tAct.setSpacingAfter(15);
+ addRow(tAct, "DOS CENTIF émises", String.valueOf(d.dosCount), normalFont);
+ addRow(tAct, "Sessions formation LBC/FT", String.valueOf(d.formationsCount), normalFont);
+ addRow(tAct, "Anomalies audit trail (SoD)", String.valueOf(d.anomaliesAuditCount), normalFont);
+ addRow(tAct, "Demandes d'aide créées", String.valueOf(d.demandesAideTraitees), normalFont);
+ doc.add(tAct);
+
+ // Section alertes
+ doc.add(new Paragraph("3. ALERTES & POINTS D'ATTENTION", sectionFont));
+ Paragraph alertes = new Paragraph();
+ alertes.setSpacingBefore(5);
+ boolean any = false;
+ if (!d.snapshot.complianceOfficerDesigne()) {
+ alertes.add(new Phrase(
+ "• CRITIQUE — Compliance Officer non désigné (Instr. BCEAO 001-03-2025)\n", normalFont));
+ any = true;
+ }
+ if ("RETARD".equals(d.snapshot.agAnnuelle().statut())) {
+ alertes.add(new Phrase("• CRITIQUE — AG annuelle en retard (échéance 30/06)\n", normalFont));
+ any = true;
+ }
+ if (d.anomaliesAuditCount > 0) {
+ alertes.add(new Phrase(
+ "• ATTENTION — " + d.anomaliesAuditCount + " violation(s) SoD détectée(s)\n",
+ normalFont));
+ any = true;
+ }
+ if (d.snapshot.scoreGlobal() < 60) {
+ alertes.add(new Phrase(
+ "• ATTENTION — Score conformité <60% (" + d.snapshot.scoreGlobal() + ")\n", normalFont));
+ any = true;
+ }
+ if (!any) {
+ alertes.add(new Phrase("Aucune alerte critique sur ce trimestre.", normalFont));
+ }
+ doc.add(alertes);
+
+ doc.add(new Paragraph("\n\nSignature du Contrôleur Interne : ____________________________",
+ normalFont));
+ doc.add(new Paragraph("Date : ____ / ____ / ________", normalFont));
+
+ doc.close();
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("Erreur génération PDF rapport trimestriel", e);
+ }
+ }
+
+ private void addRow(PdfPTable t, String label, String value, Font f) {
+ PdfPCell c1 = new PdfPCell(new Phrase(label, f));
+ PdfPCell c2 = new PdfPCell(new Phrase(value, f));
+ c1.setPadding(4f);
+ c2.setPadding(4f);
+ t.addCell(c1);
+ t.addCell(c2);
+ }
+
+ // ============================================================
+ // Hash SHA-256
+ // ============================================================
+
+ static String calculerHash(JsonNode contenu) {
+ try {
+ String canonical = contenu == null ? "" : contenu.toString();
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest(canonical.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(digest);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 indisponible", e);
+ }
+ }
+
+ // ============================================================
+ // Trimestre
+ // ============================================================
+
+ static LocalDate debutTrimestre(int annee, int trimestre) {
+ int mois = switch (trimestre) {
+ case 1 -> 1;
+ case 2 -> 4;
+ case 3 -> 7;
+ case 4 -> 10;
+ default -> throw new IllegalArgumentException("Trimestre invalide: " + trimestre);
+ };
+ return LocalDate.of(annee, mois, 1);
+ }
+
+ static LocalDate finTrimestre(int annee, int trimestre) {
+ return debutTrimestre(annee, trimestre).plusMonths(3).minusDays(1);
+ }
+
+ // ============================================================
+ // DTO interne
+ // ============================================================
+
+ record DonneesAgregees(
+ UUID orgId,
+ int annee,
+ int trimestre,
+ LocalDate debut,
+ LocalDate fin,
+ ComplianceSnapshot snapshot,
+ long dosCount,
+ long formationsCount,
+ long anomaliesAuditCount,
+ long demandesAideTraitees
+ ) {}
+}
diff --git a/src/main/resources/db/migration/V48__P2_2026_04_25_Rapport_Trimestriel_Controleur_Interne.sql b/src/main/resources/db/migration/V48__P2_2026_04_25_Rapport_Trimestriel_Controleur_Interne.sql
new file mode 100644
index 0000000..aabc228
--- /dev/null
+++ b/src/main/resources/db/migration/V48__P2_2026_04_25_Rapport_Trimestriel_Controleur_Interne.sql
@@ -0,0 +1,51 @@
+-- ============================================================================
+-- V48 — Reporting trimestriel ControleurInterne automatisé (P2-NEW-3)
+-- 2026-04-25
+--
+-- Génère un rapport agrégé par trimestre × organisation, signé par le
+-- contrôleur interne et archivé immuablement (hash SHA-256). Source pour
+-- les inspections BCEAO/ARTCI et les AG annuelles.
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS rapports_trimestriels_controleur_interne (
+ id UUID PRIMARY KEY,
+ organisation_id UUID NOT NULL REFERENCES organisations(id) ON DELETE CASCADE,
+ annee INTEGER NOT NULL CHECK (annee BETWEEN 2024 AND 2099),
+ trimestre INTEGER NOT NULL CHECK (trimestre BETWEEN 1 AND 4),
+ date_generation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ statut VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
+ CHECK (statut IN ('DRAFT', 'SIGNE', 'ARCHIVE')),
+ score_conformite INTEGER NOT NULL DEFAULT 0
+ CHECK (score_conformite BETWEEN 0 AND 100),
+ contenu_jsonb JSONB,
+ pdf_bytes BYTEA,
+ signataire_id UUID,
+ date_signature TIMESTAMP,
+ hash_sha256 VARCHAR(64),
+ -- BaseEntity
+ date_creation TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ date_modification TIMESTAMP,
+ cree_par VARCHAR(255),
+ modifie_par VARCHAR(255),
+ version BIGINT,
+ actif BOOLEAN NOT NULL DEFAULT TRUE,
+ CONSTRAINT uq_rapport_trim_org_annee_trim
+ UNIQUE (organisation_id, annee, trimestre)
+);
+
+CREATE INDEX IF NOT EXISTS idx_rapport_trim_org
+ ON rapports_trimestriels_controleur_interne (organisation_id);
+
+CREATE INDEX IF NOT EXISTS idx_rapport_trim_annee_trim
+ ON rapports_trimestriels_controleur_interne (annee, trimestre);
+
+CREATE INDEX IF NOT EXISTS idx_rapport_trim_statut
+ ON rapports_trimestriels_controleur_interne (statut)
+ WHERE statut <> 'ARCHIVE';
+
+COMMENT ON TABLE rapports_trimestriels_controleur_interne IS
+ 'Rapport trimestriel agrégé du Contrôleur Interne — source AG + inspections BCEAO/ARTCI';
+COMMENT ON COLUMN rapports_trimestriels_controleur_interne.contenu_jsonb IS
+ 'Snapshot JSON : compliance score, AML DOS count, formations LBC/FT count, KYC %, audit anomalies';
+COMMENT ON COLUMN rapports_trimestriels_controleur_interne.hash_sha256 IS
+ 'Hash SHA-256 du contenu_jsonb au moment de la signature — immuable après SIGNE';
diff --git a/src/test/java/dev/lions/unionflow/server/scheduler/RapportTrimestrielSchedulerTest.java b/src/test/java/dev/lions/unionflow/server/scheduler/RapportTrimestrielSchedulerTest.java
new file mode 100644
index 0000000..9d0f4ca
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/scheduler/RapportTrimestrielSchedulerTest.java
@@ -0,0 +1,51 @@
+package dev.lions.unionflow.server.scheduler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.LocalDate;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class RapportTrimestrielSchedulerTest {
+
+ @Test
+ @DisplayName("trimestrePrecedent — 1er avril → T1 année courante")
+ void trimestrePrecedent_1avril() {
+ var t = RapportTrimestrielScheduler.trimestrePrecedent(LocalDate.of(2026, 4, 1));
+ assertThat(t.annee()).isEqualTo(2026);
+ assertThat(t.trimestre()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("trimestrePrecedent — 1er juillet → T2 année courante")
+ void trimestrePrecedent_1juillet() {
+ var t = RapportTrimestrielScheduler.trimestrePrecedent(LocalDate.of(2026, 7, 1));
+ assertThat(t.annee()).isEqualTo(2026);
+ assertThat(t.trimestre()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("trimestrePrecedent — 1er octobre → T3 année courante")
+ void trimestrePrecedent_1octobre() {
+ var t = RapportTrimestrielScheduler.trimestrePrecedent(LocalDate.of(2026, 10, 1));
+ assertThat(t.annee()).isEqualTo(2026);
+ assertThat(t.trimestre()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("trimestrePrecedent — 1er janvier → T4 année précédente")
+ void trimestrePrecedent_1janvier_anneePrecedente() {
+ var t = RapportTrimestrielScheduler.trimestrePrecedent(LocalDate.of(2026, 1, 1));
+ assertThat(t.annee()).isEqualTo(2025);
+ assertThat(t.trimestre()).isEqualTo(4);
+ }
+
+ @Test
+ @DisplayName("trimestrePrecedent — milieu de trimestre = trimestre du mois précédent")
+ void trimestrePrecedent_milieu() {
+ // 15 mai 2026 → mois précédent = avril → T2
+ var t = RapportTrimestrielScheduler.trimestrePrecedent(LocalDate.of(2026, 5, 15));
+ assertThat(t.annee()).isEqualTo(2026);
+ assertThat(t.trimestre()).isEqualTo(2);
+ }
+}
diff --git a/src/test/java/dev/lions/unionflow/server/service/reporting/RapportTrimestrielServiceTest.java b/src/test/java/dev/lions/unionflow/server/service/reporting/RapportTrimestrielServiceTest.java
new file mode 100644
index 0000000..6a58a64
--- /dev/null
+++ b/src/test/java/dev/lions/unionflow/server/service/reporting/RapportTrimestrielServiceTest.java
@@ -0,0 +1,203 @@
+package dev.lions.unionflow.server.service.reporting;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dev.lions.unionflow.server.entity.ReferentielComptable;
+import dev.lions.unionflow.server.service.compliance.ComplianceDashboardService.ComplianceSnapshot;
+import dev.lions.unionflow.server.service.compliance.ComplianceDashboardService.ConformiteIndicateur;
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.UUID;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class RapportTrimestrielServiceTest {
+
+ private final RapportTrimestrielService service = new RapportTrimestrielService();
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ // ────────────────────────────────────────────────────────────
+ // Helpers statiques — calcul trimestre
+ // ────────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("debutTrimestre — T1 = 1er janvier")
+ void debutTrimestre_t1() {
+ assertThat(RapportTrimestrielService.debutTrimestre(2026, 1))
+ .isEqualTo(LocalDate.of(2026, 1, 1));
+ }
+
+ @Test
+ @DisplayName("debutTrimestre — T2 = 1er avril")
+ void debutTrimestre_t2() {
+ assertThat(RapportTrimestrielService.debutTrimestre(2026, 2))
+ .isEqualTo(LocalDate.of(2026, 4, 1));
+ }
+
+ @Test
+ @DisplayName("debutTrimestre — T3 = 1er juillet")
+ void debutTrimestre_t3() {
+ assertThat(RapportTrimestrielService.debutTrimestre(2026, 3))
+ .isEqualTo(LocalDate.of(2026, 7, 1));
+ }
+
+ @Test
+ @DisplayName("debutTrimestre — T4 = 1er octobre")
+ void debutTrimestre_t4() {
+ assertThat(RapportTrimestrielService.debutTrimestre(2026, 4))
+ .isEqualTo(LocalDate.of(2026, 10, 1));
+ }
+
+ @Test
+ @DisplayName("finTrimestre — T1 = 31 mars")
+ void finTrimestre_t1() {
+ assertThat(RapportTrimestrielService.finTrimestre(2026, 1))
+ .isEqualTo(LocalDate.of(2026, 3, 31));
+ }
+
+ @Test
+ @DisplayName("finTrimestre — T4 = 31 décembre")
+ void finTrimestre_t4() {
+ assertThat(RapportTrimestrielService.finTrimestre(2026, 4))
+ .isEqualTo(LocalDate.of(2026, 12, 31));
+ }
+
+ @Test
+ @DisplayName("debutTrimestre — trimestre invalide → exception")
+ void debutTrimestre_invalide() {
+ assertThatThrownBy(() -> RapportTrimestrielService.debutTrimestre(2026, 5))
+ .isInstanceOf(IllegalArgumentException.class);
+ }
+
+ // ────────────────────────────────────────────────────────────
+ // Hash SHA-256
+ // ────────────────────────────────────────────────────────────
+
+ @Test
+ @DisplayName("calculerHash — null → hash de chaîne vide (constant)")
+ void hash_null() {
+ String h = RapportTrimestrielService.calculerHash(null);
+ assertThat(h).hasSize(64);
+ // SHA-256 de "" est connu
+ assertThat(h).isEqualTo("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
+ }
+
+ @Test
+ @DisplayName("calculerHash — déterministe pour le même contenu")
+ void hash_deterministe() {
+ JsonNode n1 = mapper.createObjectNode().put("a", 1).put("b", "x");
+ JsonNode n2 = mapper.createObjectNode().put("a", 1).put("b", "x");
+ assertThat(RapportTrimestrielService.calculerHash(n1))
+ .isEqualTo(RapportTrimestrielService.calculerHash(n2));
+ }
+
+ @Test
+ @DisplayName("calculerHash — différent pour contenus différents")
+ void hash_different() {
+ JsonNode n1 = mapper.createObjectNode().put("a", 1);
+ JsonNode n2 = mapper.createObjectNode().put("a", 2);
+ assertThat(RapportTrimestrielService.calculerHash(n1))
+ .isNotEqualTo(RapportTrimestrielService.calculerHash(n2));
+ }
+
+ // ────────────────────────────────────────────────────────────
+ // Sérialisation JSON + génération PDF (sans EM)
+ // ────────────────────────────────────────────────────────────
+
+ private RapportTrimestrielService.DonneesAgregees sampleDonnees() {
+ var snapshot = new ComplianceSnapshot(
+ UUID.randomUUID(),
+ "Mutuelle Test",
+ ReferentielComptable.SYSCOHADA,
+ true,
+ new ConformiteIndicateur("OK", "AG OK"),
+ new ConformiteIndicateur("OK", "AIRMS OK"),
+ 3,
+ new BigDecimal("85.00"),
+ new BigDecimal("70.00"),
+ new ConformiteIndicateur("OPTIONNEL", "CAC optionnel"),
+ new ConformiteIndicateur("EN_VEILLE", "FOMUS-CI veille"),
+ new BigDecimal("60.00"),
+ 82
+ );
+ return new RapportTrimestrielService.DonneesAgregees(
+ snapshot.organisationId(), 2026, 1,
+ LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31),
+ snapshot, 2L, 1L, 0L, 12L);
+ }
+
+ @Test
+ @DisplayName("construireJson — contient les champs clés")
+ void json_contientChamps() {
+ var donnees = sampleDonnees();
+ JsonNode json = service.construireJson(donnees);
+
+ assertThat(json.get("annee").asInt()).isEqualTo(2026);
+ assertThat(json.get("trimestre").asInt()).isEqualTo(1);
+ assertThat(json.path("conformite").path("score_global").asInt()).isEqualTo(82);
+ assertThat(json.path("activite_trimestrielle").path("dos_centif_emises").asLong()).isEqualTo(2L);
+ assertThat(json.path("activite_trimestrielle").path("sessions_formation_lbcft").asLong())
+ .isEqualTo(1L);
+ assertThat(json.path("alertes").isArray()).isTrue();
+ }
+
+ @Test
+ @DisplayName("construireJson — alertes vides quand tout OK et score >=60")
+ void json_alertesVidesSiToutOK() {
+ var donnees = sampleDonnees();
+ JsonNode json = service.construireJson(donnees);
+ assertThat(json.path("alertes").size()).isZero();
+ }
+
+ @Test
+ @DisplayName("construireJson — alerte CRITIQUE si compliance officer absent")
+ void json_alerteComplianceOfficerAbsent() {
+ var s = sampleDonnees().snapshot();
+ var snapshotKO = new ComplianceSnapshot(
+ s.organisationId(), s.organisationNom(), s.referentielComptable(),
+ false, // ← absent
+ s.agAnnuelle(), s.rapportAirms(), s.dirigeantsAvecCmu(),
+ s.tauxKycAJourPct(), s.tauxFormationLbcFtPct(),
+ s.commissaireAuxComptes(), s.fomusCi(), s.couvertureUboPct(), s.scoreGlobal());
+ var donnees = new RapportTrimestrielService.DonneesAgregees(
+ snapshotKO.organisationId(), 2026, 1,
+ LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31),
+ snapshotKO, 0L, 0L, 0L, 0L);
+
+ JsonNode json = service.construireJson(donnees);
+ assertThat(json.path("alertes").size()).isEqualTo(1);
+ assertThat(json.path("alertes").get(0).asText()).contains("Compliance Officer non désigné");
+ }
+
+ @Test
+ @DisplayName("construireJson — alerte score <60")
+ void json_alerteScoreFaible() {
+ var s = sampleDonnees().snapshot();
+ var snapshotFaible = new ComplianceSnapshot(
+ s.organisationId(), s.organisationNom(), s.referentielComptable(),
+ s.complianceOfficerDesigne(), s.agAnnuelle(), s.rapportAirms(), s.dirigeantsAvecCmu(),
+ s.tauxKycAJourPct(), s.tauxFormationLbcFtPct(),
+ s.commissaireAuxComptes(), s.fomusCi(), s.couvertureUboPct(),
+ 45);
+ var donnees = new RapportTrimestrielService.DonneesAgregees(
+ snapshotFaible.organisationId(), 2026, 1,
+ LocalDate.of(2026, 1, 1), LocalDate.of(2026, 3, 31),
+ snapshotFaible, 0L, 0L, 0L, 0L);
+ JsonNode json = service.construireJson(donnees);
+ assertThat(json.path("alertes").size()).isEqualTo(1);
+ assertThat(json.path("alertes").get(0).asText()).contains("Score conformité <60%");
+ }
+
+ @Test
+ @DisplayName("genererPdf — produit un PDF non vide")
+ void pdf_nonVide() {
+ byte[] pdf = service.genererPdf(sampleDonnees());
+ assertThat(pdf).isNotEmpty();
+ assertThat(pdf.length).isGreaterThan(500);
+ // Header PDF magique
+ assertThat(new String(pdf, 0, 5)).startsWith("%PDF-");
+ }
+}
+ *
+ *
+ *