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
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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ public final class RoleConstant {
|
|||||||
public static final String SECRETAIRE = "SECRETAIRE";
|
public static final String SECRETAIRE = "SECRETAIRE";
|
||||||
public static final String TRESORIER = "TRESORIER";
|
public static final String TRESORIER = "TRESORIER";
|
||||||
public static final String MODERATEUR = "MODERATEUR";
|
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 ─────────────────────────────────────────────────────────
|
// ── Rôles membres ─────────────────────────────────────────────────────────
|
||||||
public static final String MEMBRE = "MEMBRE";
|
public static final String MEMBRE = "MEMBRE";
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user