feat(sprint-8 web 2026-04-25): pages PrimeFaces conformité (compliance dashboard + rapports trimestriels + PI-SPI readiness)

Front-end web JSF/PrimeFaces pour exposer les features backend Sprints 3, 5, 7 aux compliance officers et controleurs internes.

DTOs locaux (miroirs JSON, @JsonIgnoreProperties)
- ComplianceSnapshotDto + ConformiteIndicateurDto (P1-NEW-7)
- RapportTrimestrielDto + helpers estDraft/estSigne/estArchive (P2-NEW-3)
- PispiReadinessDto + CheckResultDto + helpers estReady/estBlocked (P1-NEW-15)

REST clients @RegisterRestClient configKey=unionflow-api
- ComplianceDashboardRestClient : getSnapshotCurrent + getSnapshotOf(orgId)
- RapportTrimestrielRestClient : lister, generer, signer, archiver, telechargerPdf
- PispiReadinessRestClient : getReadiness
- AuthHeaderFactory propage le token OIDC

Beans @ViewScoped
- ConformiteDashboardBean : init + rafraichir + couleurScore + hasAlertes
- RapportsTrimestrielsBean : lister, genererRapport, signerSelection, archiverSelection, telechargerPdf via ExternalContext
- PispiReadinessBean : rafraichir + gestion HTTP 503 BLOCKED + couleurStatus + couleurCheck

Pages XHTML PrimeFaces (template main-template.xhtml, classes Freya)
- /pages/secure/conformite/dashboard.xhtml — score global + 9 indicateurs en grille + alertes
- /pages/secure/conformite/rapports-trimestriels.xhtml — table DRAFT/SIGNE/ARCHIVE + bouton générer/signer/archiver/PDF
- /pages/secure/admin/pispi-readiness.xhtml — 8 checks + blocages/warnings dédiés + statut global

Tests (21/21 verts, JUnit5 natif puisque AssertJ non transitif)
- ConformiteDashboardBeanTest : 9 tests (couleur score success/warning/danger/secondary, hasAlertes 5 cas)
- PispiReadinessBeanTest : 8 tests (couleurStatus READY/DEGRADED/BLOCKED/null, couleurCheck PASS/FAIL × severity, DTO helpers)
- RapportTrimestrielDtoTest : 4 tests (estDraft/estSigne/estArchive/inconnu)
This commit is contained in:
dahoud
2026-04-25 11:02:48 +00:00
parent 0c5a027ec3
commit a7788036eb
15 changed files with 1134 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
package dev.lions.unionflow.client.api.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.math.BigDecimal;
import java.util.UUID;
/**
* Miroir local du record {@code ComplianceSnapshot} backend (P1-NEW-7).
* @since 2026-04-25 (Sprint 8)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ComplianceSnapshotDto(
UUID organisationId,
String organisationNom,
String referentielComptable,
boolean complianceOfficerDesigne,
ConformiteIndicateurDto agAnnuelle,
ConformiteIndicateurDto rapportAirms,
int dirigeantsAvecCmu,
BigDecimal tauxKycAJourPct,
BigDecimal tauxFormationLbcFtPct,
ConformiteIndicateurDto commissaireAuxComptes,
ConformiteIndicateurDto fomusCi,
BigDecimal couvertureUboPct,
int scoreGlobal
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record ConformiteIndicateurDto(String statut, String message) {}
}

View File

@@ -0,0 +1,29 @@
package dev.lions.unionflow.client.api.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
/**
* Miroir local du DTO {@code ReadinessReport} backend (P1-NEW-15).
*
* @since 2026-04-25 (Sprint 8)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record PispiReadinessDto(
String globalStatus, // READY, DEGRADED, BLOCKED
String baseUrl,
List<CheckResultDto> checks,
List<String> blockingIssues,
List<String> warnings
) {
@JsonIgnoreProperties(ignoreUnknown = true)
public record CheckResultDto(
String name,
String status, // PASS, FAIL
String severity, // BLOCKING, WARNING
String message
) {}
public boolean estReady() { return "READY".equals(globalStatus); }
public boolean estBlocked() { return "BLOCKED".equals(globalStatus); }
}

View File

@@ -0,0 +1,30 @@
package dev.lions.unionflow.client.api.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Miroir local de l'entité {@code RapportTrimestrielControleurInterne} backend (P2-NEW-3).
*
* <p>Champs JSONB et byte[] omis — récupérés via endpoint PDF dédié.
*
* @since 2026-04-25 (Sprint 8)
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record RapportTrimestrielDto(
UUID id,
UUID organisationId,
Integer annee,
Integer trimestre,
LocalDateTime dateGeneration,
String statut,
Integer scoreConformite,
UUID signataireId,
LocalDateTime dateSignature,
String hashSha256
) {
public boolean estDraft() { return "DRAFT".equals(statut); }
public boolean estSigne() { return "SIGNE".equals(statut); }
public boolean estArchive() { return "ARCHIVE".equals(statut); }
}

View File

@@ -0,0 +1,30 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.api.dto.ComplianceSnapshotDto;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/compliance/dashboard")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface ComplianceDashboardRestClient {
/** Snapshot pour l'organisation active (résolue par le backend via header X-Active-Organisation-Id). */
@GET
ComplianceSnapshotDto getSnapshotCurrent();
/** Snapshot d'une organisation arbitraire — restreint SUPER_ADMIN backend. */
@GET
@Path("/{organisationId}")
ComplianceSnapshotDto getSnapshotOf(@PathParam("organisationId") UUID organisationId);
}

View File

@@ -0,0 +1,22 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.api.dto.PispiReadinessDto;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/admin/pispi/readiness")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface PispiReadinessRestClient {
@GET
PispiReadinessDto getReadiness();
}

View File

@@ -0,0 +1,51 @@
package dev.lions.unionflow.client.service;
import dev.lions.unionflow.client.api.dto.RapportTrimestrielDto;
import dev.lions.unionflow.client.security.AuthHeaderFactory;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
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 java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
@RegisterRestClient(configKey = "unionflow-api")
@RegisterClientHeaders(AuthHeaderFactory.class)
@Path("/api/rapports/trimestriel")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface RapportTrimestrielRestClient {
@GET
List<RapportTrimestrielDto> lister(
@QueryParam("orgId") UUID orgId,
@QueryParam("annee") Integer annee);
@POST
@Path("/generer")
RapportTrimestrielDto generer(
@QueryParam("orgId") UUID orgId,
@QueryParam("annee") int annee,
@QueryParam("trimestre") int trimestre);
@POST
@Path("/{id}/signer")
RapportTrimestrielDto signer(
@PathParam("id") UUID id,
@QueryParam("signataireId") UUID signataireId);
@POST
@Path("/{id}/archiver")
RapportTrimestrielDto archiver(@PathParam("id") UUID id);
@GET
@Path("/{id}/pdf")
@Produces("application/pdf")
byte[] telechargerPdf(@PathParam("id") UUID id);
}

View File

@@ -0,0 +1,76 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.api.dto.ComplianceSnapshotDto;
import dev.lions.unionflow.client.service.ComplianceDashboardRestClient;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean du tableau de bord de conformité (Sprint 8 ⇄ backend P1-NEW-7).
*
* <p>Affiche le snapshot de l'organisation active : score global, indicateurs AG/AIRMS/CMU/KYC/UBO,
* alertes critiques. Refresh manuel par bouton.
*
* @since 2026-04-25 (Sprint 8)
*/
@Named
@ViewScoped
public class ConformiteDashboardBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(ConformiteDashboardBean.class);
@Inject @RestClient
ComplianceDashboardRestClient client;
private ComplianceSnapshotDto snapshot;
private String erreur;
@PostConstruct
public void init() {
rafraichir();
}
public void rafraichir() {
erreur = null;
try {
snapshot = client.getSnapshotCurrent();
LOG.infof("Snapshot conformité chargé : score=%d", snapshot.scoreGlobal());
} catch (Exception e) {
LOG.warnf("Échec chargement compliance dashboard : %s", e.getMessage());
erreur = "Impossible de charger le tableau de bord : " + e.getMessage();
addMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur);
snapshot = null;
}
}
public String getCouleurScore() {
if (snapshot == null) return "secondary";
int s = snapshot.scoreGlobal();
if (s >= 80) return "success";
if (s >= 60) return "warning";
return "danger";
}
public boolean hasAlertes() {
if (snapshot == null) return false;
return !snapshot.complianceOfficerDesigne()
|| "RETARD".equals(snapshot.agAnnuelle().statut())
|| snapshot.scoreGlobal() < 60;
}
private void addMessage(FacesMessage.Severity sev, String summary, String detail) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(sev, summary, detail));
}
public ComplianceSnapshotDto getSnapshot() { return snapshot; }
public String getErreur() { return erreur; }
}

View File

@@ -0,0 +1,85 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.api.dto.PispiReadinessDto;
import dev.lions.unionflow.client.service.PispiReadinessRestClient;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean d'inspection PI-SPI Readiness (Sprint 8 ⇄ P1-NEW-15).
*
* @since 2026-04-25 (Sprint 8)
*/
@Named
@ViewScoped
public class PispiReadinessBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(PispiReadinessBean.class);
@Inject @RestClient
PispiReadinessRestClient client;
private PispiReadinessDto readiness;
private String erreur;
@PostConstruct
public void init() {
rafraichir();
}
public void rafraichir() {
erreur = null;
try {
readiness = client.getReadiness();
LOG.infof("PI-SPI readiness chargé : %s (%d blocking, %d warnings)",
readiness.globalStatus(),
readiness.blockingIssues() == null ? 0 : readiness.blockingIssues().size(),
readiness.warnings() == null ? 0 : readiness.warnings().size());
} catch (jakarta.ws.rs.WebApplicationException wae) {
// Backend renvoie 503 quand BLOCKED — on récupère quand même le body
try {
readiness = wae.getResponse().readEntity(PispiReadinessDto.class);
LOG.infof("PI-SPI readiness BLOCKED — body décodé");
} catch (Exception parseEx) {
handleError("Décodage réponse readiness échoué", parseEx);
readiness = null;
}
} catch (Exception e) {
handleError("Chargement PI-SPI readiness échoué", e);
readiness = null;
}
}
public String getCouleurStatus() {
if (readiness == null) return "secondary";
return switch (readiness.globalStatus()) {
case "READY" -> "success";
case "DEGRADED" -> "warning";
case "BLOCKED" -> "danger";
default -> "secondary";
};
}
public String getCouleurCheck(String severity, String status) {
if ("PASS".equals(status)) return "success";
return "BLOCKING".equals(severity) ? "danger" : "warning";
}
private void handleError(String summary, Exception e) {
LOG.warnf("%s : %s", summary, e.getMessage());
erreur = summary + "" + e.getMessage();
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur));
}
public PispiReadinessDto getReadiness() { return readiness; }
public String getErreur() { return erreur; }
}

View File

@@ -0,0 +1,147 @@
package dev.lions.unionflow.client.view;
import dev.lions.unionflow.client.api.dto.RapportTrimestrielDto;
import dev.lions.unionflow.client.service.RapportTrimestrielRestClient;
import jakarta.annotation.PostConstruct;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Serializable;
import java.time.Year;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
/**
* Bean liste/actions sur les rapports trimestriels Contrôleur Interne (Sprint 8 ⇄ P2-NEW-3).
*
* @since 2026-04-25 (Sprint 8)
*/
@Named
@ViewScoped
public class RapportsTrimestrielsBean implements Serializable {
private static final long serialVersionUID = 1L;
private static final Logger LOG = Logger.getLogger(RapportsTrimestrielsBean.class);
@Inject @RestClient
RapportTrimestrielRestClient client;
private List<RapportTrimestrielDto> rapports = Collections.emptyList();
private int annee = Year.now().getValue();
private int trimestreCible = 1;
private RapportTrimestrielDto selection;
private String erreur;
@PostConstruct
public void init() {
rafraichir();
}
public void rafraichir() {
erreur = null;
try {
rapports = client.lister(null, annee);
LOG.infof("Liste rapports trimestriels chargée pour année %d : %d entrée(s)",
annee, rapports.size());
} catch (Exception e) {
handleError("Chargement des rapports échoué", e);
rapports = Collections.emptyList();
}
}
public void genererRapport() {
erreur = null;
try {
var nouveau = client.generer(null, annee, trimestreCible);
addMessage(FacesMessage.SEVERITY_INFO, "Rapport généré",
"Trimestre " + nouveau.trimestre() + "/" + nouveau.annee()
+ " — score " + nouveau.scoreConformite());
rafraichir();
} catch (Exception e) {
handleError("Génération du rapport échouée", e);
}
}
public void signerSelection(UUID signataireId) {
if (selection == null) {
addMessage(FacesMessage.SEVERITY_WARN, "Aucune sélection",
"Sélectionnez un rapport DRAFT à signer");
return;
}
try {
client.signer(selection.id(), signataireId);
addMessage(FacesMessage.SEVERITY_INFO, "Signé",
"Rapport " + selection.annee() + "/T" + selection.trimestre() + " signé");
rafraichir();
} catch (Exception e) {
handleError("Signature échouée", e);
}
}
public void archiverSelection() {
if (selection == null) {
addMessage(FacesMessage.SEVERITY_WARN, "Aucune sélection",
"Sélectionnez un rapport SIGNE à archiver");
return;
}
try {
client.archiver(selection.id());
addMessage(FacesMessage.SEVERITY_INFO, "Archivé",
"Rapport " + selection.annee() + "/T" + selection.trimestre() + " archivé");
rafraichir();
} catch (Exception e) {
handleError("Archivage échoué", e);
}
}
public void telechargerPdf(RapportTrimestrielDto r) {
if (r == null) return;
try {
byte[] pdf = client.telechargerPdf(r.id());
String filename = String.format("rapport-trim-%d-T%d.pdf", r.annee(), r.trimestre());
FacesContext fc = FacesContext.getCurrentInstance();
ExternalContext ec = fc.getExternalContext();
ec.responseReset();
ec.setResponseContentType("application/pdf");
ec.setResponseHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
ec.setResponseContentLength(pdf.length);
try (OutputStream os = ec.getResponseOutputStream()) {
os.write(pdf);
}
fc.responseComplete();
} catch (IOException e) {
handleError("Téléchargement PDF échoué", e);
} catch (Exception e) {
handleError("Téléchargement PDF échoué", e);
}
}
private void handleError(String summary, Exception e) {
LOG.warnf("%s : %s", summary, e.getMessage());
erreur = summary + "" + e.getMessage();
addMessage(FacesMessage.SEVERITY_ERROR, "Erreur", erreur);
}
private void addMessage(FacesMessage.Severity sev, String summary, String detail) {
FacesContext.getCurrentInstance().addMessage(null,
new FacesMessage(sev, summary, detail));
}
public List<RapportTrimestrielDto> getRapports() { return rapports; }
public int getAnnee() { return annee; }
public void setAnnee(int annee) { this.annee = annee; }
public int getTrimestreCible() { return trimestreCible; }
public void setTrimestreCible(int trimestreCible) { this.trimestreCible = trimestreCible; }
public RapportTrimestrielDto getSelection() { return selection; }
public void setSelection(RapportTrimestrielDto selection) { this.selection = selection; }
public String getErreur() { return erreur; }
}