feat(sprint-5 P2-NEW-3 2026-04-25): reporting trimestriel ControleurInterne automatisé + scheduler + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m26s

Rapport trimestriel agrégé pour le Contrôleur Interne (source AG + inspections BCEAO/ARTCI).

Entités & migration
- RapportTrimestrielControleurInterne (BaseEntity + JSONB contenu + bytea PDF + hash SHA-256)
- V48 : table rapports_trimestriels_controleur_interne, contraintes, indexes
- Repository : trouverParOrgAnneeTrimestre, listerParOrgAnnee, listerNonSignes

Service RapportTrimestrielService
- genererRapport(orgId, annee, trimestre) : agrège ComplianceSnapshot + DOS CENTIF + formations LBC/FT + anomalies SoD audit trail + demandes aide
- construireJson : structure conformite/activite/alertes
- genererPdf : OpenPDF A4 avec sections 1.Conformité 2.Activité 3.Alertes + bloc signature
- signer(rapportId, signataireId) : calcule SHA-256 du JSON, fige le statut
- archiver(rapportId) : passe SIGNE → ARCHIVE
- Idempotent en DRAFT (régénération possible) ; immuable en SIGNE/ARCHIVE

Resource RapportTrimestrielResource
- GET /api/rapports/trimestriel?orgId&annee — lister
- POST /api/rapports/trimestriel/generer — CONTROLEUR_INTERNE / SUPER_ADMIN
- POST /api/rapports/trimestriel/{id}/signer — CONTROLEUR_INTERNE
- POST /api/rapports/trimestriel/{id}/archiver — CONTROLEUR_INTERNE / PRESIDENT
- GET /api/rapports/trimestriel/{id}/pdf — application/pdf

Scheduler RapportTrimestrielScheduler
- @Scheduled cron 0 17 2 1 1,4,7,10 ? — 1er jan/avr/jul/oct à 02:17
- Génère pour toutes les orgs actives le trimestre précédent
- Override possible via unionflow.reporting.trimestriel.cron
- ConcurrentExecution.SKIP

RoleConstant
- Ajout CONTROLEUR_INTERNE, COMPLIANCE_OFFICER, COMMISSAIRE_COMPTES (utilisés depuis V45)

Tests Sprint 5 (20/20 verts)
- RapportTrimestrielServiceTest : 15 tests (debutTrimestre, finTrimestre, hash SHA-256, JSON alertes, PDF non vide)
- RapportTrimestrielSchedulerTest : 5 tests (trimestrePrecedent — incluant rollover année)
This commit is contained in:
dahoud
2026-04-25 10:13:07 +00:00
parent b0ee8881fb
commit a0b2690c17
9 changed files with 1029 additions and 0 deletions

View File

@@ -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.
*
* <p>Source documentaire pour :
* <ul>
* <li>Présentation lors des AG (rapport moral / financier / technique)</li>
* <li>Inspections BCEAO Instruction 001-03-2025 (LBC/FT)</li>
* <li>Audits ARTCI Décision 2025-1312 (DPO / sécurité données)</li>
* </ul>
*
* <p>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;
}

View File

@@ -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<RapportTrimestrielControleurInterne, UUID> {
public Optional<RapportTrimestrielControleurInterne> trouverParOrgAnneeTrimestre(
UUID orgId, int annee, int trimestre) {
return find("organisationId = ?1 AND annee = ?2 AND trimestre = ?3",
orgId, annee, trimestre).firstResultOptional();
}
public List<RapportTrimestrielControleurInterne> listerParOrgAnnee(UUID orgId, int annee) {
return list("organisationId = ?1 AND annee = ?2 ORDER BY trimestre", orgId, annee);
}
public List<RapportTrimestrielControleurInterne> listerNonSignes() {
return list("statut = 'DRAFT' ORDER BY dateGeneration");
}
}

View File

@@ -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).
*
* <p>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<RapportTrimestrielControleurInterne> 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();
}
}

View File

@@ -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.
*
* <p>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) {}
}

View File

@@ -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";

View File

@@ -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.
*
* <p>Pour chaque {@code (organisation, année, trimestre)} :
* <ol>
* <li>Agrège les données : score conformité, DOS CENTIF, formations LBC/FT, KYC, audit trail
* <li>Sérialise en JSON (colonne {@code contenu_jsonb})
* <li>Génère un PDF OpenPDF (colonne {@code pdf_bytes})
* <li>Persiste — ou met à jour si rapport DRAFT déjà existant
* </ol>
*
* <p>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
) {}
}

View File

@@ -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';

View File

@@ -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);
}
}

View File

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