From d04a6258112378af33eff989e82cac9f10ac2990 Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:50:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(sprint-17=20web=202026-04-25):=20page=20pu?= =?UTF-8?q?blique=20/pages/public/kpi.xhtml=20=E2=80=94=20consultation=20K?= =?UTF-8?q?PI=20sign=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Page anonyme accessible aux autorités externes via URL signée (HMAC-SHA256 backend). DRY strict — pas de DTO miroir, réutilise KpiPublicSnapshot officiel api 1.0.10. PublicKpiRestClient (@RegisterRestClient sans AuthHeaderFactory — endpoint anonyme) - consulter(token) → KpiPublicSnapshot PublicKpiBean @ViewScoped - charger() lit query param token via FacesContext / viewParam, appelle endpoint - Gestion erreurs HTTP : 401 → "Token invalide ou expiré", 404 → "Organisation introuvable" - Helpers couleurScore (>=80 success / >=60 warning / <60 danger), couleurStatut Page /pages/public/kpi.xhtml - Template public-template.xhtml (sans auth, sans menu) - f:viewParam token + f:viewAction publicKpiBean.charger - Card KPI : score coloré central + 8 indicateurs en grid (Compliance Officer, AG, AIRMS, CMU, KYC, formation LBC/FT, UBO, CAC) - Badge transparency footer : "Toute consultation est tracée dans l'audit trail" - Whitelisté via existing /pages/public/* dans application.properties Bump api 1.0.9 → 1.0.10 Tests (8/8) PublicKpiBean - couleurScore × 4 (success/warning/danger/null) - couleurStatut × 6 cas - isCharge avec/sans snapshot --- pom.xml | 2 +- .../client/service/PublicKpiRestClient.java | 25 +++ .../unionflow/client/view/PublicKpiBean.java | 97 +++++++++++ .../META-INF/resources/pages/public/kpi.xhtml | 152 ++++++++++++++++++ .../client/view/PublicKpiBeanTest.java | 79 +++++++++ 5 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java create mode 100644 src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java create mode 100644 src/main/resources/META-INF/resources/pages/public/kpi.xhtml create mode 100644 src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java diff --git a/pom.xml b/pom.xml index b58e0d0..8e94e19 100644 --- a/pom.xml +++ b/pom.xml @@ -142,7 +142,7 @@ dev.lions.unionflow unionflow-server-api - 1.0.9 + 1.0.10 diff --git a/src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java b/src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java new file mode 100644 index 0000000..8b2de4a --- /dev/null +++ b/src/main/java/dev/lions/unionflow/client/service/PublicKpiRestClient.java @@ -0,0 +1,25 @@ +package dev.lions.unionflow.client.service; + +import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +/** + * Client REST public KPI (Sprint 17 — pas de header auth, token signé en query). + * + *

Note : pas de {@code @RegisterClientHeaders(AuthHeaderFactory.class)} car endpoint anonyme. + */ +@RegisterRestClient(configKey = "unionflow-api") +@Path("/api/public/kpi") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public interface PublicKpiRestClient { + + @GET + KpiPublicSnapshot consulter(@QueryParam("token") String token); +} diff --git a/src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java b/src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java new file mode 100644 index 0000000..11bf2b2 --- /dev/null +++ b/src/main/java/dev/lions/unionflow/client/view/PublicKpiBean.java @@ -0,0 +1,97 @@ +package dev.lions.unionflow.client.view; + +import dev.lions.unionflow.client.service.PublicKpiRestClient; +import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot; +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 public consultation KPI (Sprint 17) — pas d'authentification requise. + * + *

Lit le query param {@code token} dès l'init via {@code @ViewScoped} + viewAction, + * appelle l'endpoint public, expose le snapshot à la page. + * + * @since 2026-04-25 (Sprint 17) + */ +@Named +@ViewScoped +public class PublicKpiBean implements Serializable { + + private static final long serialVersionUID = 1L; + private static final Logger LOG = Logger.getLogger(PublicKpiBean.class); + + @Inject @RestClient PublicKpiRestClient client; + + private String token; + private KpiPublicSnapshot snapshot; + private String erreur; + + /** Appelé via {@code }. */ + public void charger() { + erreur = null; + snapshot = null; + + if (token == null || token.isBlank()) { + // Token vient du query param via + token = FacesContext.getCurrentInstance() + .getExternalContext() + .getRequestParameterMap() + .get("token"); + } + + if (token == null || token.isBlank()) { + erreur = "Aucun token fourni dans l'URL."; + return; + } + + try { + snapshot = client.consulter(token); + LOG.infof("PublicKpi chargé — score=%d", snapshot == null ? -1 : snapshot.scoreGlobal()); + } catch (jakarta.ws.rs.WebApplicationException wae) { + int status = wae.getResponse().getStatus(); + if (status == 401) { + erreur = "Token invalide ou expiré."; + } else if (status == 404) { + erreur = "Organisation introuvable."; + } else { + erreur = "Erreur HTTP " + status; + } + LOG.warnf("PublicKpi HTTP %d : %s", status, wae.getMessage()); + } catch (Exception e) { + erreur = "Erreur de chargement : " + e.getMessage(); + LOG.errorf(e, "PublicKpi exception"); + } + } + + /** Couleur sémantique du score (success / warning / danger). */ + 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 String getCouleurStatut(String statut) { + if (statut == null) return "secondary"; + return switch (statut) { + case "OK" -> "success"; + case "RETARD" -> "danger"; + case "EN_ATTENTE" -> "warning"; + case "OBLIGATOIRE" -> "info"; + default -> "secondary"; + }; + } + + // Getters / setters + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + public KpiPublicSnapshot getSnapshot() { return snapshot; } + public String getErreur() { return erreur; } + public boolean isCharge() { return snapshot != null; } +} diff --git a/src/main/resources/META-INF/resources/pages/public/kpi.xhtml b/src/main/resources/META-INF/resources/pages/public/kpi.xhtml new file mode 100644 index 0000000..a2f1a01 --- /dev/null +++ b/src/main/resources/META-INF/resources/pages/public/kpi.xhtml @@ -0,0 +1,152 @@ + + + + UnionFlow - KPI publics + + + + + + + + + +

+
+
+
+ +

+ Indicateurs publics de conformité +

+

+ Vue read-only destinée aux autorités externes (BCEAO, ARTCI, CENTIF). +

+
+ + + +
+ +

Accès impossible

+

#{publicKpiBean.erreur}

+
+
+ + + +
+
+
+
Score conformité
+
+ #{publicKpiBean.snapshot.scoreGlobal} +
+
/ 100
+
+
+
+
+
Organisation
+
#{publicKpiBean.snapshot.organisationNom}
+
+ Référentiel comptable : + #{publicKpiBean.snapshot.referentielComptable} +
+
+ Snapshot généré le : #{publicKpiBean.snapshot.dateGeneration} +
+
+
+
+ + +
+
+
+
+ Compliance Officer + +
+
+
+ +
+
+
+ AG annuelle + +
+
+
+ +
+
+
+ Rapport AIRMS + +
+
+
+ +
+
+
Dirigeants enrôlés CMU
+
#{publicKpiBean.snapshot.dirigeantsAvecCmu}
+
+
+ +
+
+
Taux KYC à jour
+
#{publicKpiBean.snapshot.tauxKycAJourPct} %
+
+
+ +
+
+
Taux formation LBC/FT
+
#{publicKpiBean.snapshot.tauxFormationLbcFtPct} %
+
+
+ +
+
+
Couverture UBO
+
#{publicKpiBean.snapshot.couvertureUboPct} %
+
+
+ +
+
+
+ Commissaire aux comptes + +
+
+
+
+ + +
+ + Vue partagée temporairement via lien signé. Toute consultation est tracée + dans l'audit trail UnionFlow. +
+
+
+
+
+ + diff --git a/src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java b/src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java new file mode 100644 index 0000000..166be17 --- /dev/null +++ b/src/test/java/dev/lions/unionflow/client/view/PublicKpiBeanTest.java @@ -0,0 +1,79 @@ +package dev.lions.unionflow.client.view; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.lions.unionflow.server.api.dto.compliance.response.KpiPublicSnapshot; +import java.lang.reflect.Field; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PublicKpiBeanTest { + + private PublicKpiBean bean; + + @BeforeEach + void setUp() { + bean = new PublicKpiBean(); + } + + private void setSnapshot(KpiPublicSnapshot s) throws Exception { + Field f = PublicKpiBean.class.getDeclaredField("snapshot"); + f.setAccessible(true); + f.set(bean, s); + } + + // ── couleurScore ───────────────────────────────────────────────────── + + @Test + @DisplayName("couleurScore — score >= 80 → success") + void couleurSuccess() throws Exception { + setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(85).build()); + assertEquals("success", bean.getCouleurScore()); + } + + @Test + @DisplayName("couleurScore — 60..79 → warning") + void couleurWarning() throws Exception { + setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(70).build()); + assertEquals("warning", bean.getCouleurScore()); + } + + @Test + @DisplayName("couleurScore — < 60 → danger") + void couleurDanger() throws Exception { + setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(50).build()); + assertEquals("danger", bean.getCouleurScore()); + } + + @Test + @DisplayName("couleurScore — null snapshot → secondary") + void couleurNull() { + assertEquals("secondary", bean.getCouleurScore()); + } + + // ── couleurStatut ──────────────────────────────────────────────────── + + @Test + @DisplayName("couleurStatut — règles") + void couleurStatut() { + assertEquals("success", bean.getCouleurStatut("OK")); + assertEquals("danger", bean.getCouleurStatut("RETARD")); + assertEquals("warning", bean.getCouleurStatut("EN_ATTENTE")); + assertEquals("info", bean.getCouleurStatut("OBLIGATOIRE")); + assertEquals("secondary", bean.getCouleurStatut(null)); + assertEquals("secondary", bean.getCouleurStatut("AUTRE")); + } + + // ── État charge ────────────────────────────────────────────────────── + + @Test + @DisplayName("isCharge — false par défaut, true quand snapshot présent") + void isCharge() throws Exception { + assertFalse(bean.isCharge()); + setSnapshot(KpiPublicSnapshot.builder().scoreGlobal(75).build()); + assertTrue(bean.isCharge()); + } +}