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 { + + public Optional trouverParOrgAnneeTrimestre( + UUID orgId, int annee, int trimestre) { + return find("organisationId = ?1 AND annee = ?2 AND trimestre = ?3", + orgId, annee, trimestre).firstResultOptional(); + } + + public List listerParOrgAnnee(UUID orgId, int annee) { + return list("organisationId = ?1 AND annee = ?2 ORDER BY trimestre", orgId, annee); + } + + public List listerNonSignes() { + return list("statut = 'DRAFT' ORDER BY dateGeneration"); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/resource/RapportTrimestrielResource.java b/src/main/java/dev/lions/unionflow/server/resource/RapportTrimestrielResource.java new file mode 100644 index 0000000..149ff43 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/resource/RapportTrimestrielResource.java @@ -0,0 +1,88 @@ +package dev.lions.unionflow.server.resource; + +import dev.lions.unionflow.server.entity.RapportTrimestrielControleurInterne; +import dev.lions.unionflow.server.repository.RapportTrimestrielControleurInterneRepository; +import dev.lions.unionflow.server.security.OrganisationContextHolder; +import dev.lions.unionflow.server.service.reporting.RapportTrimestrielService; +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import java.util.UUID; + +/** + * Endpoints du Reporting trimestriel ControleurInterne (P2-NEW-3). + * + *

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 lister( + @QueryParam("orgId") UUID orgId, + @QueryParam("annee") Integer annee) { + UUID effectiveOrg = orgId != null ? orgId : orgContext.getOrganisationId(); + int effectiveAnnee = annee != null ? annee : java.time.Year.now().getValue(); + return repository.listerParOrgAnnee(effectiveOrg, effectiveAnnee); + } + + @POST + @Path("/generer") + @RolesAllowed({"CONTROLEUR_INTERNE", "SUPER_ADMIN"}) + public RapportTrimestrielControleurInterne generer( + @QueryParam("orgId") UUID orgId, + @QueryParam("annee") int annee, + @QueryParam("trimestre") int trimestre) { + UUID effectiveOrg = orgId != null ? orgId : orgContext.getOrganisationId(); + return service.genererRapport(effectiveOrg, annee, trimestre); + } + + @POST + @Path("/{id}/signer") + @RolesAllowed({"CONTROLEUR_INTERNE", "SUPER_ADMIN"}) + public RapportTrimestrielControleurInterne signer( + @PathParam("id") UUID id, @QueryParam("signataireId") UUID signataireId) { + return service.signer(id, signataireId); + } + + @POST + @Path("/{id}/archiver") + @RolesAllowed({"CONTROLEUR_INTERNE", "PRESIDENT", "SUPER_ADMIN"}) + public RapportTrimestrielControleurInterne archiver(@PathParam("id") UUID id) { + return service.archiver(id); + } + + @GET + @Path("/{id}/pdf") + @Produces("application/pdf") + @RolesAllowed({"CONTROLEUR_INTERNE", "PRESIDENT", "ADMIN_ORGANISATION", "SUPER_ADMIN"}) + public Response telechargerPdf(@PathParam("id") UUID id) { + RapportTrimestrielControleurInterne r = repository.findById(id); + if (r == null || r.getPdfBytes() == null) { + throw new NotFoundException("Rapport ou PDF introuvable : " + id); + } + String filename = String.format("rapport-trim-%d-T%d.pdf", r.getAnnee(), r.getTrimestre()); + return Response.ok(r.getPdfBytes()) + .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") + .build(); + } +} diff --git a/src/main/java/dev/lions/unionflow/server/scheduler/RapportTrimestrielScheduler.java b/src/main/java/dev/lions/unionflow/server/scheduler/RapportTrimestrielScheduler.java new file mode 100644 index 0000000..796d1b3 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/server/scheduler/RapportTrimestrielScheduler.java @@ -0,0 +1,72 @@ +package dev.lions.unionflow.server.scheduler; + +import dev.lions.unionflow.server.entity.Organisation; +import dev.lions.unionflow.server.repository.OrganisationRepository; +import dev.lions.unionflow.server.service.reporting.RapportTrimestrielService; +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import java.time.LocalDate; +import java.time.YearMonth; +import org.jboss.logging.Logger; + +/** + * Génération automatique des rapports trimestriels du Contrôleur Interne au début de chaque + * trimestre (1er janvier / avril / juillet / octobre à 02:17), pour le trimestre précédent. + * + *

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)} : + *

    + *
  1. Agrège les données : score conformité, DOS CENTIF, formations LBC/FT, KYC, audit trail + *
  2. Sérialise en JSON (colonne {@code contenu_jsonb}) + *
  3. Génère un PDF OpenPDF (colonne {@code pdf_bytes}) + *
  4. Persiste — ou met à jour si rapport DRAFT déjà existant + *
+ * + *

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-"); + } +}