Compare commits

..

27 Commits

Author SHA1 Message Date
dahoud
07302f2743 fix(sprint-17 backend): désactivation globale DevServices en test (H2 + OIDC off → aucun container nécessaire)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
Suite à observation que le précédent fix ciblé `quarkus.keycloak.devservices.enabled=false`
n'empêchait pas le démarrage du container KC (probablement timing build-time vs runtime).

Mode test :
- DataSource : H2 in-memory (lignes 5-8 application-test.properties — pas de Postgres needed)
- OIDC : tenant-enabled=false (pas de KC needed)

Conclusion : aucun DevService nécessaire en test → désactivation globale via
`quarkus.devservices.enabled=false` (couvre tous les services) + garde
`quarkus.keycloak.devservices.enabled=false` en sécurité.

Bénéfice : tests démarrent en 5-10s au lieu de 1-3min (boot containers KC + ryuk).
Mode `quarkus:dev` reste full DevServices (logique, on a besoin de KC en dev).
2026-04-25 17:47:59 +00:00
dahoud
af8d237d01 fix(sprint-17 backend): désactiver Keycloak DevServices en test (OIDC déjà off → KC inutile)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m9s
Erreur observée :
  ERROR [io.qu.de.ke.KeycloakDevServicesProcessor] Admin token can not be acquired
  due to a client connection timeout
  (~50s boot Docker + timeouts admin token)

Cause : @QuarkusTest charge application-test.properties qui désactive OIDC
(tenant-enabled=false), mais l'extension quarkus-keycloak-* déclenche quand même
le DevService Keycloak qui télécharge/démarre un container KC 26.3.4 et tente
d'obtenir un admin token — qui timeout.

Fix non-appauvrissant : désactiver UNIQUEMENT le DevService KC en mode test.
- Dev mode : DevServices KC reste actif (utile pour quarkus:dev)
- Test mode : OIDC déjà désactivé → KC DevServices = pure perte de temps de boot
- Postgres DevServices reste actif (Hibernate a besoin d'une DB pour les tests JPA)

Ajout :
  quarkus.keycloak.devservices.enabled=false  (test-only via application-test.properties)
2026-04-25 17:34:05 +00:00
dahoud
d52b0f6f2b fix(sprint-17 backend): JwtPropagationFilter @ApplicationScoped — découverte CDI pour @QuarkusTest
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
JwtPropagationFilterTest @QuarkusTest fait @Inject JwtPropagationFilter mais la
classe n'avait aucun scope CDI → UnsatisfiedResolutionException au démarrage du test.

Le commentaire interdisait @Provider (auto-register JAX-RS global qui écraserait le
service account des AdminUserServiceClient/AdminRoleServiceClient). Mais ne disait
rien contre @ApplicationScoped : c'est un scope CDI ≠ provider JAX-RS.

Fix : ajouter @ApplicationScoped (rend découvrable par CDI mais ne provoque PAS
d'enregistrement automatique JAX-RS — l'opt-in reste via @RegisterProvider explicite).
Suppression de l'import jakarta.ws.rs.ext.Provider devenu inutile.

Commentaire de classe enrichi pour clarifier la nuance scope-CDI vs provider-JAX-RS.
2026-04-25 17:21:42 +00:00
dahoud
2826d75aa6 fix(sprint-17 backend): 3 warnings build/dev — Qute -parameters, compiler version, policy-enforcer deprecation
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m52s
3 fixes pour build dev mode propre :

1. Qute @CheckedTemplate (EmailTemplateService.Templates) cassait au démarrage
   → maven-compiler-plugin <parameters>true</parameters> (noms params en bytecode)

2. Maven warning "build.plugins.plugin.version missing"
   → ajout <version>3.13.0</version> au compiler-plugin (cohérent avec api module)

3. Quarkus runtime warning "quarkus.keycloak.policy-enforcer.enable is deprecated"
   → renommé en policy-enforcer.enabled dans application.properties + application-test.properties
   (depuis Quarkus 3.x, propriété renommée pour cohérence avec autres flags enabled)
2026-04-25 17:14:51 +00:00
dahoud
0e264b3c1f fix(sprint-17 backend): KpiShareTokenService.DEFAULT_TTL_SECONDS public (referenced from KpiShareLinkResource in different package)
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m44s
2026-04-25 17:01:54 +00:00
dahoud
ed0d74e124 feat(sprint-17 backend 2026-04-25): Public KPI Sharing — token signé HMAC-SHA256 + endpoints admin/public
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m3s
Transparency réglementaire (Sprint 16.D différé livré en S17). Permet aux autorités
externes (BCEAO, ARTCI, CENTIF, contrôleurs UEMOA) de consulter les KPI agrégés
d'une organisation sans login, via lien signé temporairement.

Architecture sécurité
- KpiShareTokenService : HMAC-SHA256 + base64url, format orgId.expiryMillis.signature
- Vérification time-constant via MessageDigest.isEqual (résistant timing attacks)
- Secret @ConfigProperty unionflow.kpi.share.secret (défaut TTL 7 jours)
- Pas de DB — token autosuffisant, révocable seulement par rotation du secret

KpiPublicService
- snapshotPublic(orgId) : extrait sous-ensemble safe de ComplianceSnapshot
- Audit chaque accès public (méta-traçabilité — qui/quand consulté quoi)

Endpoints
- GET /api/admin/kpi/share-link/{orgId}?ttlSeconds=...
  @RolesAllowed ADMIN_ORGANISATION, PRESIDENT, COMPLIANCE_OFFICER, SUPER_ADMIN
  Retourne {token, ttlSeconds, publicUrl, publicWebPath}
- GET /api/public/kpi?token=...
  @PermitAll (whitelist /api/public/* dans application.properties)
  Retourne KpiPublicSnapshot ou 401/404 si token invalide/expiré ou org introuvable

Bump dépendance api 1.0.9 → 1.0.10 (DTO KpiPublicSnapshot ajouté)

Tests KpiShareTokenService (9 tests)
- Round-trip orgId, ttl 0/<=0 défaut, orgId null exception
- Token expiré (signature fake), tampering signature, tampering orgId
- Malformé (< 3 parts, > 4 parts)
- Null/blank
- Encode/decode round-trip avec caractères spéciaux

ACTION USER : mvn install api 1.0.10 puis tester impl/web.
2026-04-25 16:48:47 +00:00
dahoud
0c46d9bad6 feat(sprint-16 backend 2026-04-25): transparency operations — Métriques Prometheus + Export multi-format + Notifications auto
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m35s
Trois piliers transparency opérationnelle UnionFlow livrés en série :

S16.A — Métriques Prometheus métier (transparency observabilité)
- AuditTrailService.log() incrémente :
  - unionflow.audit.operations{action, entity}
  - unionflow.audit.sod_violations{entity}
- application.properties : quarkus.micrometer.export.prometheus.enabled=true,
  http-server + jvm binders activés
- Endpoint /q/metrics exposé pour scraping Prometheus K8s

S16.B — Export massif audit-trail (transparency réglementaire BCEAO/ARTCI/CENTIF)
- AuditTrailExportService :
  - exportCsv : Apache Commons CSV avec BOM UTF-8 (compat Excel)
  - exportXlsx : POI XSSFWorkbook avec header coloré et auto-size
  - exportPdf : OpenPDF A4 paysage avec table 7 colonnes
- Endpoint GET /api/audit-trail/export?format=csv|xlsx|pdf&scope=...&limit=500
- @RolesAllowed COMPLIANCE_OFFICER, CONTROLEUR_INTERNE, SUPER_ADMIN
- Auto-log de l'export lui-même dans audit (méta-traçabilité)

S16.C — Notifications transparency (pattern CDI Event Observer)
- AuditOperationLoggedEvent record + helper estSensible() (DELETE / PAYMENT_* / BUDGET_APPROVED / AID_REQUEST_APPROVED / EXPORT / VALIDATE / SoD-violation)
- AuditTrailService.log() fire le CDI event après persist
- AuditNotificationService observe @Observes(AFTER_SUCCESS) — notifie via NotificationService existant (DRY, pas de nouveau client mail)
- Sujets/corps localisés français + priorité automatique (HAUTE pour DELETE/PAYMENT_FAILED/SoD, NORMALE pour confirmations, BASSE pour exports)
- Fail-soft : exception notification ne bloque jamais l'audit principal

Tests (10 nouveaux S16.C, 21/21 cumulé audit)
- onAuditOperation × 5 (sensible/non-sensible/SoD/null/userId-null)
- mapping helpers × 4 (sujet × 8 actions, sodPriority, priorité × 6 cas, corps × 2)
2026-04-25 16:28:51 +00:00
dahoud
e4d7c8e4b7 feat(sprint-15 backend 2026-04-25): Live Activity Feed — endpoint /api/audit-trail/recent + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m50s
Transparency opérationnelle UnionFlow — opérations sensibles consultables en temps réel selon scope.

Repository
- findRecent(limit) : N opérations les plus récentes, toutes orgs
- findRecentByOrganisation(orgId, limit) : pour scope ORG
- findRecentByUser(userId, limit) : pour scope SELF
- Tous clamps limit à [1, 500] (sécurité DoS)

Service AuditTrailQueryService
- listerRecentes(scope, orgId, userId, limit) : dispatcher SELF/ORG/ALL avec fallback liste vide si UUID requis manquant ; insensible à la casse

Resource REST AuditTrailOperationResource
- GET /api/audit-trail/recent?scope=SELF&orgId=...&userId=...&limit=50
- Default scope=SELF, limit=50
- @RolesAllowed étendu (MEMBRE inclus pour scope SELF) — un membre peut voir SES propres opérations

Tests (7 nouveaux, 11/11 cumulé service)
- recentes_All / Null defaults to ALL / Org / OrgWithoutId / Self / SelfWithoutUser / CaseInsensitive
2026-04-25 16:08:33 +00:00
dahoud
7c3352ed48 feat(sprint-13.A backend 2026-04-25): OrganisationService.convertToResponse propage referentielComptable + complianceOfficerId
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m21s
Suite à enrichissement OrganisationResponse api 1.0.9. Mapping Entity → Response
maintenant complet pour le front-end (Sprint 10 avait déjà le mapping
Request → Entity, manquait Entity → Response).

Bump dépendance api 1.0.8 → 1.0.9. Quarkus inchangé (3.27.3).

ACTION USER : `mvn install` côté unionflow-server-impl-quarkus pour valider.
2026-04-25 15:22:50 +00:00
dahoud
f267eeebfc fix(sprint-10 backend): @Builder n'expose pas actif() (parent BaseEntity), setter explicite
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m27s
BaseEntity contient actif (Boolean), mais BeneficiaireEffectif et RoleDelegation utilisent
@Builder simple (pas @SuperBuilder) → builder ne propage pas les champs parent.

Solution : .build() puis setActif(true). Pas de @SuperBuilder ici (changement de signature
diffuse, hors-scope Sprint 10).

Tests Sprint 10 backend désormais 12/12 verts (BeneficiaireEffectifService 8 + AuditTrailQueryService 4).
2026-04-25 12:46:22 +00:00
dahoud
241533efa6 feat(sprint-10 backend 2026-04-25): Resources REST UBO + audit trail + délégation + enrich update org
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m30s
Expose les features Sprints 1-2 via API REST. Architecture stricte respectée :
Resource ↔ Service ↔ Repository ↔ Entity ↔ DTO. Mapping centralisé en Service.

Bump dépendance api 1.0.7 → 1.0.8 (DTOs nouveaux)

UBO (Instr. BCEAO 003-03-2025)
- BeneficiaireEffectifService : creer (validation cible obligatoire), mettreAJour (PATCH), desactiver (soft-delete), validation seuilPourcentage (somme cumul ≤ 100, exclusion en mode update), audit trail systématique
- BeneficiaireEffectifResource : CRUD /api/kyc/beneficiaires-effectifs, filtres queryParam (kycDossierId | organisationCibleId | pep), @RolesAllowed COMPLIANCE_OFFICER + ADMIN_ORG + SUPER_ADMIN

Audit trail (CQRS read-side)
- AuditTrailQueryService NEW : 5 méthodes lecture seule (parUtilisateur, historiqueEntite, parOrganisation, violationsSod, operationsFinancieres) + mapping Entity→DTO
- AuditTrailOperationResource : /api/audit-trail/{by-user|by-entity|by-organisation|sod-violations|financial}, parsing dates ISO + fallbacks
- Distinct du AuditTrailService (write-side dans security/) : Single Responsibility appliqué

Délégation rôles (Sprint 2 service réutilisé)
- RoleDelegationService enrichi : creerDepuisRequest (DTO→entité→creer existant), revoquerEtRetourner, listerParOrganisation, toResponse (mapping centralisé)
- RoleDelegationRepository : findByOrganisation
- RoleDelegationResource : POST/DELETE/GET /api/role-delegations

Enrichissement Organisation update (DRY)
- OrganisationService.convertFromUpdateRequest : parseReferentielComptable (fallback ReferentielComptable.defaultFor) + complianceOfficerId
- OrganisationService.mettreAJourOrganisation : propagation conditionnelle des nouveaux champs

Tests (en attente publication api 1.0.8 — install local nécessaire)
- BeneficiaireEffectifServiceTest : 8 tests verifierSeuilPourcentage (OK / limite / dépassement / excludeId / null / inactifs ignorés) + creer + toResponse mapping
- AuditTrailQueryServiceTest : 4 tests parUtilisateur, sodViolations, toResponse mapping, historiqueEntite

ACTION USER : `mvn install` côté unionflow-server-api pour rendre 1.0.8 dispo en m2 local + publier via script/publish-api.sh pour Gitea.
2026-04-25 12:32:41 +00:00
dahoud
4d400dc48d feat(sprint-7 P1-NEW-15 2026-04-25): PI-SPI Readiness check (8 vérifications) + endpoint admin
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Service ops/compliance pour valider l'état d'intégration PI-SPI BCEAO avant activation production.

PispiReadinessService — 8 vérifications structurées
- OAUTH2_CREDENTIALS (BLOCKING) : client_id + client_secret
- API_KEY (BLOCKING) : header X-API-Key obligatoire BCEAO
- MTLS_KEYSTORE (BLOCKING) : path + password + fichier .p12 existant
- MTLS_TRUSTSTORE (WARNING) : configuré + fichier existant (fallback cacerts si absent)
- WEBHOOK_SECRET (BLOCKING) : HMAC-SHA256 webhooks
- BASE_URL (WARNING) : sandbox vs production détecté automatiquement
- TAUX_EUR_XOF (WARNING) : taux récent ≤ 7 jours (sinon obsolète)
- PROVIDER_CONFIGURED (BLOCKING) : PispiAuth.isConfigured() = true (synthèse)

Statut global agrégé
- READY — tous PASS
- DEGRADED — uniquement WARNING en échec (sandbox OK, prod à risque)
- BLOCKED — au moins un BLOCKING en échec (sandbox impossible)

Endpoint /api/admin/pispi/readiness
- @RolesAllowed SUPER_ADMIN, COMPLIANCE_OFFICER
- HTTP 200 si READY/DEGRADED, 503 si BLOCKED
- Réponse JSON : globalStatus, baseUrl, checks détaillés, blockingIssues, warnings

Helpers publics ajoutés (pour readiness sans casser l'encapsulation)
- PispiAuth : hasClientId(), hasClientSecret(), hasApiKey(), keystorePath(), keystorePassword(), truststorePath(), truststorePassword()
- PispiSignatureVerifier : hasWebhookSecret()

Tests (15/15 verts)
- BLOCKED tout vide, READY tout configuré, DEGRADED warnings only
- Checks individuels OAuth2/ApiKey/Keystore (path absent / fichier inexistant / OK), Truststore warning, Webhook, BaseUrl sandbox/prod, Taux récent/obsolète, Provider
2026-04-25 10:48:59 +00:00
dahoud
86842f27af feat(sprint-6 P2-NEW-7 2026-04-25): multi-devise + KYC non-résident diaspora + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m11s
Ouvre UnionFlow à la diaspora UEMOA (France, USA, Canada, UK, Suisse...).

Entités & migration
- Enum Devise (10 valeurs : XOF, XAF, EUR, USD, GBP, CAD, CHF, GHS, NGN, MAD)
  - Zones : UEMOA, CEMAC, CEDEAO, EUROPE, AMERIQUE, MAGHREB
  - DEVISES_INTERNATIONALES : EUR/USD/GBP/CAD/CHF (déclenchent AML international)
- Entité TauxChange (devise_source × devise_cible × date_validite, taux NUMERIC(18,8))
- Repository : trouverExact, trouverPlusRecent (≤ date)
- V49 :
  - Table taux_change (contrainte unicité paire+date, devises distinctes)
  - Seed BCEAO_FIXED EUR↔XOF + taux indicatifs USD/GBP/CAD au 2026-04-25
  - Membres : pays_residence (ISO-3), numero_passeport, numero_fiscal_etranger, est_diaspora, devise_preferee
  - KycDossiers : pays_origine_fonds, justificatif_residence_etrangere, niveau_due_diligence (SIMPLIFIE/STANDARD/RENFORCE)

DeviseConversionService
- Stratégie de résolution : direct → inverse → pivot via XOF → fallback récent ≤ date
- Cache thread-safe (ConcurrentHashMap, TTL 1h)
- TauxIntrouvableException si aucun taux résolvable
- invaliderCache() pour reload après import batch

KycDiasporaService
- validerCoherence : passeport obligatoire si diaspora, pays_residence ≠ UEMOA, format passeport regex
- determinerNiveauDueDiligence (Instr. BCEAO 001-03-2025) :
  - PEP → RENFORCE
  - Diaspora pays sécurisés (UE/G7/Asie) → STANDARD
  - Diaspora FATF grey-list → RENFORCE
  - Diaspora pays inconnu → RENFORCE par prudence
- depasseSeuilAmlInternational : seuil 1000 EUR équivalent, false sur devises locales
- PAYS_UEMOA hardcodé (8 pays), PAYS_GREY_LIST_FATF snapshot 2026-04-25

Tests Sprint 6 (34/34 verts)
- DeviseTest : 5 tests (référence, internationales, zones, libellés)
- DeviseConversionServiceTest : 10 tests (identité, direct, inverse, pivot XOF, fallback récent, cache, invalider, exception, inputs invalides)
- KycDiasporaServiceTest : 19 tests (cohérence valide/sans passeport/pays UEMOA/pays étranger, due diligence PEP/FRA/grey-list/inconnu/UEMOA, seuil EUR/USD avec taux/sans taux/XOF/null)
2026-04-25 10:33:05 +00:00
dahoud
a0b2690c17 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)
2026-04-25 10:13:07 +00:00
dahoud
b0ee8881fb feat(sprint-4 P1-NEW-8/9 2026-04-25): docker-compose Keycloak 26.6.1 + fix Mockito strict-stubbing test KC
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m25s
P1-NEW-8/9 — Préparation migration Keycloak 23 → 26.6.1 + Organizations GA
- docker-compose.kc26.yml : compose alternatif KC 26.6.1 avec --features=organization
- Bind-mount realm import depuis src/main/resources/keycloak/realms
- Healthcheck readiness probe + KC_HEALTH_ENABLED
- Postgres 15 dédié, volume persistant
- Réf : ARCH_KEYCLOAK_26.md
- Permet validation locale avant cutover (compose dev intact)

Fix tests KeycloakAdminHttpClientTest (4 erreurs Mockito UnnecessaryStubbing)
- Helper mockResponse : lenient() sur body() (lu uniquement sur paths erreur/JSON parsing)
- 9/9 tests passent désormais

État Sprint 4 (déjà livré dans sessions antérieures, validé ici) :
- Entité Organisation.keycloakOrgId + migration V37
- Service MigrerOrganisationsVersKeycloakService (idempotent, 9 tests)
- OrganisationContextResolver (parsing claim 'organization' JWT, 12 tests)
- AdminKeycloakOrganisationResource (endpoint admin migration)
- KeycloakAdminHttpClient (sessions, 9 tests)

Tests Sprint 4 : 36/36 passent (resolver 12 + migration 9 + admin HTTP 9 + context holder 6)

Reste pour migration finale (post-Sprint 4) :
- Validation manuelle docker compose -f docker-compose.kc26.yml up -d
- Bascule production : remplacer compose KC23 par KC26
- Refactoring backend : suppression OrganisationContextFilter + headers custom
2026-04-25 08:58:00 +00:00
dahoud
8b589477ec feat(sprint-3 P1+P2 2026-04-25): compliance dashboard + PEP screening + formations LBC/FT + goAML XML + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m15s
P1-NEW-11 — Tableau de bord conformité
- ComplianceDashboardService : score 0-100 avec 9 indicateurs pondérés (compliance officer, AG annuelle, rapport AIRMS, dirigeants CMU, taux KYC, taux formation LBC/FT, CAC, FOMUS-CI, couverture UBO)
- ComplianceDashboardResource : GET /api/compliance/dashboard

P1-NEW-11 — PEP screening externe
- PepScreeningProvider (interface) + records PepScreeningResult / PepMatch
- InMemoryPepScreeningProvider : fallback dev avec similarité Levenshtein normalisée (seuil 0.80)
- PepScreeningService : cache LRU 5000 entrées, TTL 24h
- Pluggable via @Alternative @Priority pour Youverify / ComplyAdvantage en prod

P1-NEW-12 — Module formation LBC/FT obligatoire annuelle
- FormationLbcFt + ParticipationFormationLbcFt (entités)
- FormationLbcFtService : creer, inscrire, marquerPresent, certifier (score >= 70), estCertifieAnneeCourante
- Repositories : trouverCertificationAnnee, findATenir
- V47 migration : formations_lbcft, participations_formation_lbcft, pep_screening_cache

P2-NEW-4 — goAML XML (anticipation adoption CI)
- GoAmlXmlService : génération XML standard ONUDC (report, transaction, t_person, t_account)
- Reporting person anonymisé ([REDACTED], rôle Compliance Officer)
- POST /api/aml/dos/goaml @RolesAllowed(COMPLIANCE_OFFICER, SUPER_ADMIN)

Tests Sprint 3 (15 tests, 100%) :
- FormationLbcFtTest : 2 tests (builder, override type)
- GoAmlXmlServiceTest : 4 tests (XML non vide, champs clés, anonymisation, country code CIV)
- InMemoryPepScreeningProviderTest : 9 tests (match, nationalité, vide, similarité Levenshtein)
2026-04-25 08:37:06 +00:00
c54092bd78 feat(sprint-2 P0+P1 2026-04-25): DOS CENTIF + Reporting AIRMS + PV OHADA + workflow demande aide v2 + délégation rôles + SYCEBNL donateurs + tests
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m10s
P0-NEW-16 — DOS CENTIF (Lignes directrices CENTIF mars 2025)
  - DosCentifService.genererDosWord() : XWPFDocument structurée 6 sections
  - DosCentifService.genererReleveExcel() : XSSFWorkbook avec opérations atypiques
  - DosCentifResource @RolesAllowed COMPLIANCE_OFFICER : POST /api/aml/dos/{word,excel}
  - Confidentialité absolue (jamais persisté disque, audit trail EXPORT)

P1-NEW-1 — Reporting AIRMS triple
  - RapportAirmsService.genererRapportTriple() : OpenPDF, 3 sections + cover
  - DTOs records : RapportTechnique (effectifs, sinistralité, délais),
    RapportMoral (vie associative, formations, activités),
    RapportFinancier (P&L, ratios prudentiels, trésorerie, fonds dédiés SYCEBNL)
  - Endpoint POST /api/airms/rapports/triple

P1-NEW-2 — Module PV OHADA (AG/CA)
  - V46 : table proces_verbaux (quorum, OJ JSONB, résolutions JSONB, hash SHA-256, signatures)
  - Entité ProcesVerbal + repo ProcesVerbalRepository
  - ProcesVerbalService :
    * quorumRequisDefaut() : 50% AG ord / 66.67% AG extra/CA
    * calculerEtFixerQuorum() : (présents+représentés)/convoqués × 100
    * adopter() : valide quorum, fige hash SHA-256
    * signer() : vérif hash inchangé (immuabilité), signature électronique
    * archiver() : statut ARCHIVE pour conservation OHADA 10 ans

P1-NEW-3 — Workflow demande d'aide v2
  - V46 : extension demandes_aide (etape, animateur_zone, gps_enquete, avis_comite, decision_ca)
  - V46 : extension types_aide (plafond_annuel_membre, plafond_enveloppe_annuelle, justificatifs_requis)
  - DemandeAide enrichie : 5 étapes DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
  - DemandeAideV2Service :
    * Transitions typées avec checks d'état
    * SoD : approbateur CA ≠ animateur enquête
    * Audit trail enrichi à chaque transition

P1-NEW-5 — Délégation temporaire rôles
  - V46 : table role_delegations (delegant, delegataire, role, dates, statut, motif)
  - Entité RoleDelegation + isActiveAt(instant)
  - RoleDelegationService :
    * creer() : vérif SoD avant création (pas de conflit avec rôles existants délégataire)
    * revoquer() : statut REVOQUEE
    * rolesEffectifs() : directs ∪ délégués actifs
    * marquerExpirees() : scheduler quotidien

P1-NEW-13 — Registre donateurs SYCEBNL
  - V46 : tables donateurs + dons_recus (numéraire/nature/bénévolat/legs)
  - Affectation : LIBRE / FONDS_DEDIE / PROJET_SPECIFIQUE
  - Reçu fiscal (numero_recu, date_emission_recu)
  - Entités Donateur + DonRecu + repos
  - DonRecuRepository.totalEntre() pour reporting AIRMS/SYCEBNL

P1-NEW-14 — Membres honoraires/bienfaiteurs
  - V46 : ALTER membres_organisations.qualite_speciale (HONORAIRE/BIENFAITEUR/FONDATEUR)

Tests Sprint 2 (13 nouveaux, 0 failure) :
  - ProcesVerbalServiceTest (8) : quorum défaut, atteint/non-atteint, hash format/immuabilité/diff
  - RoleDelegationTest (5) : isActiveAt selon période et statut
2026-04-25 01:53:16 +00:00
7099f554fe feat(p0-2026-04-25): mobile SPKI pinning + Play Integrity/App Attest + tests Sprint 1
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m7s
P0-NEW-21 — SPKI Pinning + rotation Firebase Remote Config (mobile)
  - lib/core/security/spki_pinning_service.dart : digest SHA-256 SPKI/cert
  - Liste de pins active depuis Firebase Remote Config (key 'spki_pins')
  - Fallback statique au bundle si Firebase indisponible
  - Multi-pin (leaf + backup + intermediate) pour transitions sans downtime
  - Hosts pinnés : api.lions.dev, security.lions.dev
  - Câblé dans ApiClient._configureSslPinning() (remplace check CN obsolète)

P0-NEW-22 — Play Integrity (Android) + App Attest (iOS) (mobile)
  - lib/core/security/app_device_integrity_service.dart
  - Token attestation court (cache 60s) avec challenge backend
  - Bypass automatique en kDebugMode
  - À compléter avec un Dio interceptor X-Device-Integrity-Token avant go-live

pubspec.yaml :
  - freerasp 7.0.0 → 7.5.1
  - +app_device_integrity 1.1.0
  - +firebase_core 3.6.0 + firebase_remote_config 5.1.3

Tests Sprint 1 (40 tests, 0 failure) :
  - ReferentielComptableTest (6 cas) : defaultFor mapping
  - AmlSeuilsTest (10 cas) : seuils 10M/5M/1M, pays UEMOA, depasseSeuil
  - SoDPermissionCheckerTest (13 cas) : validation distincte, combinations interdites,
    compliance officer eligibility
  - PispiRtpRequestTest (6 cas) : validation contraintes
  - PispiRtpResponseTest (5 cas) : helpers status
2026-04-25 01:24:53 +00:00
144137656f feat(p0-2026-04-25): PI-SPI auth 3-facteurs + RTP + alias
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m32s
P0-NEW-2 — Authentification PI-SPI 3-facteurs (spec sandbox developer.pispi.bceao.int)
  - PispiAuth : OAuth2 + mTLS (PKCS12 keystore + truststore optionnel) + X-API-Key
  - SSLContext TLS 1.3 + KeyManagerFactory + TrustManagerFactory
  - HttpClient mTLS lazy + cache, HTTP/2, timeout 15s/30s
  - isConfigured() pour bascule auto mode mock si secrets absents

P0-NEW-3 — Request To Pay (RTP — pain.013/014)
  - dto/PispiRtpRequest (record validate())
  - dto/PispiRtpResponse avec helpers isAccepted()/isRefused()/...
  - PispiClient.initiateRtp() + getRtpStatus()
  - Cas d'usage : appel cotisation initié par la SFD vers le membre

P0-NEW-4 — Gestion d'alias (téléphone/email → compte SFD)
  - dto/PispiAlias (record + Types : PHONE_NUMBER/EMAIL/NATIONAL_ID/CUSTOM)
  - PispiClient.resolveAlias() + createAlias() + revokeAlias()
  - Cas d'usage : '+22507XXX@unionflow' ou 'cotisation-{slug}@unionflow'

PispiClient harmonisé : baseRequestBuilder() central avec 3 headers obligatoires, gestion 404 sur resolveAlias, timeouts 30s.
2026-04-25 01:18:46 +00:00
d8006c8425 feat(p0-2026-04-25): multi-référentiel comptable + UBO + audit trail + SoD + seuils AML
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m11s
Sprint 1 P0 (consolidation 2026-04-25, ETAT_PROJET_METIER_2026-04-25.md) :

P0-NEW-9/10/11 — Multi-référentiel comptable
  - enum ReferentielComptable (SYSCOHADA / SYCEBNL / PCSFD_UMOA)
  - Organisation.referentielComptable + mapping defaultFor(typeOrganisation)
  - V43 : colonne + check + index + mapping initial des orgs existantes

P0-NEW-13 — Bénéficiaires effectifs (UBO) — Instruction BCEAO 003-03-2025
  - Entité BeneficiaireEffectif + repository
  - V44 : table beneficiaires_effectifs (FK kyc_dossier, UBO + PEP + sanctions)
  - Conservation 10 ans (directive 02/2015/CM/UEMOA)

P0-NEW-14 — Compliance Officer (Instruction BCEAO 001-03-2025)
  - Organisation.complianceOfficerId + V43 colonne + index

P0-NEW-15 — Seuils AML alignés (Instruction BCEAO 002-03-2025)
  - AmlSeuils : 10M FCFA intra-UEMOA / 5M FCFA entrée-sortie / 1M FCFA espèce
  - Liste pays UEMOA ISO 3166-1
  - Méthodes seuilApplicable() / depasseSeuil() / depasseSeuilEspece()

P0-NEW-17/18 — Rôles PRESIDENT + CONTROLEUR_INTERNE + suppléants
  - V45 seed : PRESIDENT, VICE_PRESIDENT, CONTROLEUR_INTERNE, ANIMATEUR_ZONE, SECRETAIRE_ADJOINT, TRESORIER_ADJOINT
  - Catégories GOUVERNANCE / CONTROLE / OPERATIONNEL

P0-NEW-19 — Audit trail enrichi (SYSCOHADA + AUDSCGIE)
  - V45 : table audit_trail_operations (acteur, action, contexte multi-org, payload JSONB, SoD)
  - Entité AuditTrailOperation + AuditTrailOperationRepository
  - AuditTrailService (log avec contexte automatique depuis OrganisationContextHolder)
  - OrganisationContextHolder enrichi (roleActif, currentUserId, currentUserEmail)

P0-NEW-20 — SoD (Separation of Duties) — SYSCOHADA + AUDSCGIE + BCEAO Circulaire 03-2017
  - SoDPermissionChecker.checkValidationDistinct() (4-eyes principle)
  - .checkRoleCombination() (combinaisons interdites : Trésorier+Président, etc.)
  - .checkComplianceOfficerEligibility() (Instruction BCEAO 001-03-2025)
  - SoDCheckResult record avec audit trail automatique

P0-NEW-24 — Champ numero_cmu sur Membre (Loi 2014-131 CI)
  - Membre.numeroCMU + V43 colonne + check format 11 caractères + index
  - Auto-déclaration (pas d'API publique CNAM disponible)

BUILD SUCCESS.
2026-04-25 01:15:25 +00:00
6e9841b3bb fix(disaster-recovery 2/2): restaurer 242 fichiers Java modifiés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m22s
Suite à la récupération précédente (044ca4b) qui n'avait restauré que les
fichiers SUPPRIMÉS, ce commit restaure les MODIFICATIONS d'entités/services
qui étaient nécessaires pour que les fichiers restaurés compilent.

Restaurés depuis a72ab54^ (= 31330d9 + corrections) :
- Entities : Organisation, FormuleAbonnement, AuditService, MembreOrganisation, SouscriptionOrganisation, etc.
- Services : MigrerOrganisationsVersKeycloakService, ComptabilitePdfService, KycAmlService, AuditService.logKycRisqueEleve, etc.
- Resources : PaiementUnifieResource, etc.

Backend compile désormais (BUILD SUCCESS).
2026-04-25 01:05:08 +00:00
044ca4bd7e fix(disaster-recovery): restaurer 134 fichiers accidentellement supprimés par a72ab54
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 3m46s
Le commit a72ab54 (chore docker Dockerfile racine) a involontairement balayé
des fichiers du commit 31330d9 (PI-SPI, KYC, RLS, mutuelle parts, comptabilité
PDF) lors d'un git add -A trop large.

Restauration de l'intégralité des fichiers depuis a72ab54^ :
- 11 migrations Flyway V32-V42 (parts sociales, SYSCOHADA, Keycloak Org Id, KYC, RLS, Provider défaut, FCM, App DB Roles)
- Package payment/pispi/ complet (PispiAuth, PispiClient, PispiIso20022Mapper, PispiSignatureVerifier, PispiWebhookResource, dto/Pacs008Request, dto/Pacs002Response, PispiPaymentProvider)
- Package payment/{wave,orangemoney,mtnmomo}/* (PaymentProvider impls)
- Package payment/orchestration/ (PaymentOrchestrator, PaymentProviderRegistry)
- Entités KycDossier, mutuelle/parts/* (ComptePartsSociales, TransactionPartsSociales)
- Mappers, repositories, resources associés
- Services KycAmlService, ComptabilitePdfService, ReleveComptePdfService, InteretsEpargneService
- AdminKeycloakOrganisationResource, KycResource, PaiementUnifieResource
- Tests unitaires PI-SPI, KYC, mutuelle parts
2026-04-25 01:00:03 +00:00
b434282000 docs: Quarkus 3.15.1→3.27.3 LTS, Java 17→21, lionsctl -j 21, Dockerfile racine, pré-requis infra
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m9s
2026-04-24 18:05:43 +00:00
a72ab54abd chore(docker): add root Dockerfile pinning ubi8/openjdk-21:1.21 + UID 1001 for lionsctl pipeline
Some checks failed
CI/CD Pipeline / pipeline (push) Failing after 4m2s
2026-04-24 16:19:25 +00:00
fb3a32817b chore(quarkus-327): bump to Quarkus 3.27.3 LTS, make pom autonomous, fix 3 tests (NPE guard, equalsHashCode with shared refs), rename deprecated config keys 2026-04-23 14:45:54 +00:00
dahoud
8cec38f7b3 fix(monitoring): corriger calcul CPU — getProcessCpuLoad au lieu de loadAverage/processors
Le calcul précédent `getSystemLoadAverage() / getAvailableProcessors() * 100`
utilisait :
- getSystemLoadAverage() : charge 1min du NODE Linux hôte entier (12 CPU sur prod VPS)
- getAvailableProcessors() : limite conteneur K8s (1 CPU pour le pod UnionFlow)

Résultat : load 1.5 sur le node → cpuUsage = (1.5 / 1) * 100 = 150% capé à 100%.
Déclenchement constant d'alertes "CPU 100%" (19 faux positifs / 24h sur prod
le 20-21/04/2026) dès que le node fait autre chose que dormir.

Correctif : utilise com.sun.management.OperatingSystemMXBean.getProcessCpuLoad()
qui retourne la charge CPU du process JVM courant (0.0-1.0), conscient du
conteneur. Branche -1 préservée (API non dispo sur certaines JVM).

Tests mis à jour : AlertMonitoringServiceMockStaticCoverageTest injecte
désormais un com.sun.management.OperatingSystemMXBean et mocke
getProcessCpuLoad() (compatible mock-maker-inline déjà configuré).
AlertMonitoringServiceTest conserve sa logique OS-agnostique via des
thresholds extrêmes (-1 toujours / 100 jamais).
2026-04-21 13:38:31 +00:00
dahoud
31330d95e9 feat: accumulated work — PI-SPI, KYC, RLS, mutuelle parts, comptabilité PDF + startup fixes
## PI-SPI BCEAO (P0.3 — deadline 30/06/2026)
- package payment/pispi/ complet : PispiAuth (OAuth2), PispiClient (HTTP brut),
  PispiIso20022Mapper (pacs.008/002), PispiSignatureVerifier (HMAC-SHA256),
  PispiWebhookResource (/api/pispi/webhook), DTOs ISO 20022
- PaymentOrchestrator + PaymentProviderRegistry pour l'orchestration multi-provider
- Mode mock automatique si credentials absents (dev)

## KYC AML
- entity/KycDossier, KycResource, KycAmlService + tests
- Migration V38 (create_kyc_dossier_table)

## RLS (PostgreSQL Row-Level Security) — isolation multi-tenant
- RlsConnectionInitializer, RlsContextInterceptor, @RlsEnabled annotation
- Migration V39 (PostgreSQL RLS Tenant Isolation) + V42 (app DB roles)
- Tests unitaires RlsConnectionInitializerTest, RlsContextInterceptorTest
- Tests d'intégration RlsCrossTenantIsolationTest (@QuarkusTest + IntegrationTestProfile)

## Mutuelle — Parts sociales
- entity/mutuelle/parts/ComptePartsSociales, TransactionPartsSociales
- Service, resource, mapper, repository + tests
- InteretsEpargneService + ReleveComptePdfService

## Comptabilité PDF
- ComptabilitePdfService (OpenPDF), ComptabilitePdfResource
- Tests ComptabilitePdfServiceTest, ComptabilitePdfResourceTest

## Migrations Flyway (SYSCOHADA + Keycloak Orgs)
- V36 SYSCOHADA Plan Comptable Complet : seeds comptes standards UEMOA,
  trigger init_plan_comptable_organisation, alignement schéma V1 → entités
- V37 keycloak_org_id sur organisations (P0.2 migration KC 26)
- V40 provider_defaut sur FormuleAbonnement
- V41 fcm_token sur utilisateurs (FCM notifications push)

## Fixes startup (SmallRye Config 3.20 + schéma)
- 8× @ConfigProperty(defaultValue = "") → Optional<String>
  (firebase, pispi.*, mtnmomo, orange) — empty default rejetés par SmallRye 3.20
- application.properties : mappings secrets env var sous %prod. uniquement
- V36 : drop colonne obsolète 'numero' de V1 quand Hibernate a créé 'numero_compte'
- V36 : remplacement UNIQUE global sur journaux_comptables.code par composite
  (organisation_id, code) pour autoriser plusieurs orgs avec code 'ACH'/'VTE'/etc
- V39 : escape placeholder ${VAR} → <VAR> dans lignes commentées
  (Flyway parser évalue les placeholders même dans les commentaires)
- V41 : table 'membres' → 'utilisateurs' (nom correct selon entité Membre)
- JournalComptable entity : @UniqueConstraint composite au lieu de unique=true
- MembreResource : example @Schema JSON valide (['...'] → [])
- IntegrationTestProfile : auto-détection Docker via `docker info`, fallback
  vers PostgreSQL local sans DevServices

## Dev config
- application-dev.properties : quarkus.devservices.enabled=false +
  quarkus.kafka.devservices.enabled=false (pas besoin de Docker pour dev)
- quarkus.flyway.placeholder-replacement=false
- Secrets dev (wave.*, firebase, pispi) en mode mock automatique

## Phase 8 tests (complète)
- 170 fichiers modifiés/ajoutés, 23425+ insertions
- Tests RBAC (@QuarkusTest) pour MembreResource lifecycle
- Tests OrganisationContextFilter multi-org
- Tests SouscriptionQuotaOptionC, KycAmlService, EmailTemplate, etc.

Résultat : Backend démarre en 64s sur port 8085 avec 36 features installées.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:40:55 +00:00
472 changed files with 70632 additions and 37332 deletions

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Dockerfile for unionflow-server-impl-quarkus
# Used by lionsctl pipeline. Expects `mvn clean package -Pprod` to have produced target/quarkus-app/ (fast-jar).
FROM registry.access.redhat.com/ubi8/openjdk-21:1.21
ENV LANGUAGE='en_US:en'
COPY --chown=1001:1001 target/quarkus-app/lib/ /deployments/lib/
COPY --chown=1001:1001 target/quarkus-app/*.jar /deployments/
COPY --chown=1001:1001 target/quarkus-app/app/ /deployments/app/
COPY --chown=1001:1001 target/quarkus-app/quarkus/ /deployments/quarkus/
USER 1001
EXPOSE 8080
ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager"
ENV JAVA_APP_JAR="/deployments/quarkus-run.jar"
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD curl -f http://localhost:8080/q/health/live || exit 1
ENTRYPOINT [ "java", "-jar", "/deployments/quarkus-run.jar" ]

View File

@@ -1,7 +1,7 @@
# UnionFlow Backend - API REST Quarkus # UnionFlow Backend - API REST Quarkus
![Java](https://img.shields.io/badge/Java-17-blue) ![Java](https://img.shields.io/badge/Java-17-blue)
![Quarkus](https://img.shields.io/badge/Quarkus-3.15.1-red) ![Quarkus](https://img.shields.io/badge/Quarkus-3.27.3_LTS-red)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-blue) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15-blue)
![Kafka](https://img.shields.io/badge/Kafka-Enabled-orange) ![Kafka](https://img.shields.io/badge/Kafka-Enabled-orange)
![License](https://img.shields.io/badge/License-Proprietary-red) ![License](https://img.shields.io/badge/License-Proprietary-red)
@@ -64,7 +64,7 @@ Tous les repositories étendent `PanacheRepositoryBase<Entity, UUID>` pour :
| Composant | Version | Usage | | Composant | Version | Usage |
|-----------|---------|-------| |-----------|---------|-------|
| **Java** | 17 (LTS) | Langage | | **Java** | 17 (LTS) | Langage |
| **Quarkus** | 3.15.1 | Framework application | | **Quarkus** | 3.27.3 LTS | Framework application |
| **Hibernate ORM (Panache)** | 6.4+ | Persistence | | **Hibernate ORM (Panache)** | 6.4+ | Persistence |
| **PostgreSQL** | 15 | Base de données | | **PostgreSQL** | 15 | Base de données |
| **Flyway** | 9.22+ | Migrations DB | | **Flyway** | 9.22+ | Migrations DB |
@@ -482,7 +482,7 @@ src/test/java/
lionsctl pipeline \ lionsctl pipeline \
-u https://git.lions.dev/lionsdev/unionflow-server-impl-quarkus \ -u https://git.lions.dev/lionsdev/unionflow-server-impl-quarkus \
-b main \ -b main \
-j 17 \ -j 21 \
-e production \ -e production \
-c k1 \ -c k1 \
-p prod -p prod
@@ -490,12 +490,19 @@ lionsctl pipeline \
# Étapes : # Étapes :
# 1. Clone repo Git # 1. Clone repo Git
# 2. mvn clean package -Pprod # 2. mvn clean package -Pprod
# 3. docker build + push registry.lions.dev # 3. docker build -f Dockerfile (racine, fast-jar, ubi8/openjdk-21:1.21, UID 1001)
# 4. kubectl apply -f k8s/ # 4. push registry.lions.dev
# 5. Health check # 5. kubectl apply (Deployment + Service + Ingress)
# 6. Email notification # 6. Health check
# 7. Email notification
``` ```
**Pré-requis infrastructure** avant pipeline (migration Helm → lionsctl pipeline) :
- Secret K8s `unionflow-server-impl-quarkus-db-secret` (clés `QUARKUS_DATASOURCE_USERNAME` + `QUARKUS_DATASOURCE_PASSWORD`)
- DB PostgreSQL `unionflow` (override `QUARKUS_DATASOURCE_JDBC_URL` sur le deployment car lionsctl nomme la DB comme l'app)
- Deployment Helm existant supprimé au préalable (selector immutable)
- Service selector à repatcher après pipeline (retirer les labels `app.kubernetes.io/*`)
### Fichiers Kubernetes ### Fichiers Kubernetes
**Localisation** : `src/main/kubernetes/` **Localisation** : `src/main/kubernetes/`

73
docker-compose.kc26.yml Normal file
View File

@@ -0,0 +1,73 @@
version: '3.8'
# Compose alternatif Keycloak 26.6.1 avec feature Organizations native (GA depuis 26.0).
# Usage : docker compose -f docker-compose.kc26.yml up -d
# But : valider la migration KC23 → KC26 + Organizations en local, sans toucher au compose dev.
#
# Une fois la migration validée, basculer ce contenu en production et supprimer la stack KC23.
#
# Réf : ARCH_KEYCLOAK_26.md
services:
postgres-keycloak:
image: postgres:15-alpine
container_name: kc26-postgres
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
POSTGRES_INITDB_ARGS: "--encoding=UTF-8 --lc-collate=C --lc-ctype=C"
volumes:
- kc26_postgres_data:/var/lib/postgresql/data
networks:
- kc26-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
keycloak:
image: quay.io/keycloak/keycloak:26.6.1
container_name: kc26-server
command:
- start-dev
- --features=organization
- --http-port=8180
- --import-realm
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres-keycloak:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_HEALTH_ENABLED: "true"
KC_METRICS_ENABLED: "true"
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
ports:
- "8180:8180"
volumes:
- ./src/main/resources/keycloak/realms:/opt/keycloak/data/import:ro
depends_on:
postgres-keycloak:
condition: service_healthy
networks:
- kc26-net
healthcheck:
test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/8180 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'UP'"]
interval: 15s
timeout: 5s
retries: 8
start_period: 60s
restart: unless-stopped
volumes:
kc26_postgres_data:
driver: local
networks:
kc26-net:
driver: bridge

36
pom.xml
View File

@@ -4,14 +4,9 @@
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <groupId>dev.lions.unionflow</groupId>
<groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-parent</artifactId>
<version>1.0.6</version>
<relativePath/> <!-- Force resolution from Maven repo (Gitea); enables standalone clones -->
</parent>
<artifactId>unionflow-server-impl-quarkus</artifactId> <artifactId>unionflow-server-impl-quarkus</artifactId>
<version>1.0.7</version>
<packaging>jar</packaging> <packaging>jar</packaging>
<name>UnionFlow Server Implementation (Quarkus)</name> <name>UnionFlow Server Implementation (Quarkus)</name>
@@ -23,9 +18,13 @@
<maven.compiler.release>21</maven.compiler.release> <maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<quarkus.platform.version>3.20.0</quarkus.platform.version> <quarkus.platform.version>3.27.3</quarkus.platform.version>
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id> <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id> <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
<lombok.version>1.18.38</lombok.version>
<!-- Overrides BOM : Docker Desktop 29.x compat -->
<testcontainers.version>1.21.4</testcontainers.version>
<docker-java.version>3.4.2</docker-java.version>
<!-- Jacoco --> <!-- Jacoco -->
<jacoco.version>0.8.12</jacoco.version> <jacoco.version>0.8.12</jacoco.version>
@@ -40,6 +39,20 @@
<type>pom</type> <type>pom</type>
<scope>import</scope> <scope>import</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- Lombok : pas dans Quarkus BOM -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
@@ -48,14 +61,14 @@
<dependency> <dependency>
<groupId>dev.lions.unionflow</groupId> <groupId>dev.lions.unionflow</groupId>
<artifactId>unionflow-server-api</artifactId> <artifactId>unionflow-server-api</artifactId>
<version>1.0.6</version> <version>1.0.10</version>
</dependency> </dependency>
<!-- Lions User Manager API (pour DTOs et client Keycloak) --> <!-- Lions User Manager API (pour DTOs et client Keycloak) -->
<dependency> <dependency>
<groupId>dev.lions.user.manager</groupId> <groupId>dev.lions.user.manager</groupId>
<artifactId>lions-user-manager-server-api</artifactId> <artifactId>lions-user-manager-server-api</artifactId>
<version>1.0.0</version> <version>1.1.0</version>
</dependency> </dependency>
<!-- Quarkus Core --> <!-- Quarkus Core -->
@@ -321,7 +334,10 @@
<plugin> <plugin>
<groupId>org.apache.maven.plugins</groupId> <groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId> <artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration> <configuration>
<!-- Quarkus Qute @CheckedTemplate exige les noms de paramètres en bytecode -->
<parameters>true</parameters>
<annotationProcessorPaths> <annotationProcessorPaths>
<path> <path>
<groupId>org.mapstruct</groupId> <groupId>org.mapstruct</groupId>

View File

@@ -1,138 +1,138 @@
package de.lions.unionflow.server.auth; package de.lions.unionflow.server.auth;
import jakarta.annotation.security.PermitAll; import jakarta.annotation.security.PermitAll;
import jakarta.ws.rs.GET; import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path; import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
/** /**
* Resource temporaire pour gérer les callbacks d'authentification OAuth2/OIDC depuis l'application * Resource temporaire pour gérer les callbacks d'authentification OAuth2/OIDC depuis l'application
* mobile. * mobile.
*/ */
@Path("/auth") @Path("/auth")
@PermitAll @PermitAll
public class AuthCallbackResource { public class AuthCallbackResource {
private static final Logger log = Logger.getLogger(AuthCallbackResource.class); private static final Logger log = Logger.getLogger(AuthCallbackResource.class);
/** /**
* Endpoint de callback pour l'authentification OAuth2/OIDC. Redirige vers l'application mobile * Endpoint de callback pour l'authentification OAuth2/OIDC. Redirige vers l'application mobile
* avec les paramètres reçus. * avec les paramètres reçus.
*/ */
@GET @GET
@Path("/callback") @Path("/callback")
public Response handleCallback( public Response handleCallback(
@QueryParam("code") String code, @QueryParam("code") String code,
@QueryParam("state") String state, @QueryParam("state") String state,
@QueryParam("session_state") String sessionState, @QueryParam("session_state") String sessionState,
@QueryParam("error") String error, @QueryParam("error") String error,
@QueryParam("error_description") String errorDescription) { @QueryParam("error_description") String errorDescription) {
try { try {
// Log des paramètres reçus pour debug // Log des paramètres reçus pour debug
log.infof("=== CALLBACK DEBUG === Code: %s, State: %s, Session State: %s, Error: %s, Error Description: %s", log.infof("=== CALLBACK DEBUG === Code: %s, State: %s, Session State: %s, Error: %s, Error Description: %s",
code, state, sessionState, error, errorDescription); code, state, sessionState, error, errorDescription);
// URL de redirection simple vers l'application mobile // URL de redirection simple vers l'application mobile
String redirectUrl = "dev.lions.unionflow-mobile://callback"; String redirectUrl = "dev.lions.unionflow-mobile://callback";
// Si nous avons un code d'autorisation, c'est un succès // Si nous avons un code d'autorisation, c'est un succès
if (code != null && !code.isEmpty()) { if (code != null && !code.isEmpty()) {
redirectUrl += "?code=" + code; redirectUrl += "?code=" + code;
if (state != null && !state.isEmpty()) { if (state != null && !state.isEmpty()) {
redirectUrl += "&state=" + state; redirectUrl += "&state=" + state;
} }
} else if (error != null) { } else if (error != null) {
redirectUrl += "?error=" + error; redirectUrl += "?error=" + error;
if (errorDescription != null) { if (errorDescription != null) {
redirectUrl += "&error_description=" + errorDescription; redirectUrl += "&error_description=" + errorDescription;
} }
} }
// Page HTML simple qui redirige automatiquement vers l'app mobile // Page HTML simple qui redirige automatiquement vers l'app mobile
String html = String html =
""" """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<title>Redirection vers UnionFlow</title> <title>Redirection vers UnionFlow</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <style>
body { body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
text-align: center; text-align: center;
padding: 50px; padding: 50px;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%); background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
color: white; color: white;
} }
.container { .container {
max-width: 400px; max-width: 400px;
margin: 0 auto; margin: 0 auto;
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
padding: 30px; padding: 30px;
border-radius: 10px; border-radius: 10px;
} }
.spinner { .spinner {
border: 4px solid rgba(255,255,255,0.3); border: 4px solid rgba(255,255,255,0.3);
border-top: 4px solid white; border-top: 4px solid white;
border-radius: 50%%; border-radius: 50%%;
width: 40px; width: 40px;
height: 40px; height: 40px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
margin: 20px auto; margin: 20px auto;
} }
@keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } } @keyframes spin { 0%% { transform: rotate(0deg); } 100%% { transform: rotate(360deg); } }
a { color: #ffeb3b; text-decoration: none; } a { color: #ffeb3b; text-decoration: none; }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<h2>🔐 Authentification réussie</h2> <h2>🔐 Authentification réussie</h2>
<div class="spinner"></div> <div class="spinner"></div>
<p>Redirection vers l'application UnionFlow...</p> <p>Redirection vers l'application UnionFlow...</p>
<p><small>Si la redirection ne fonctionne pas automatiquement, <p><small>Si la redirection ne fonctionne pas automatiquement,
<a href="%s">cliquez ici</a></small></p> <a href="%s">cliquez ici</a></small></p>
</div> </div>
<script> <script>
// Tentative de redirection automatique // Tentative de redirection automatique
setTimeout(function() { setTimeout(function() {
window.location.href = '%s'; window.location.href = '%s';
}, 2000); }, 2000);
// Fallback: ouvrir l'app mobile si possible // Fallback: ouvrir l'app mobile si possible
setTimeout(function() { setTimeout(function() {
try { try {
window.open('%s', '_self'); window.open('%s', '_self');
} catch(e) { } catch(e) {
console.log('Redirection manuelle nécessaire'); console.log('Redirection manuelle nécessaire');
} }
}, 3000); }, 3000);
</script> </script>
</body> </body>
</html> </html>
""" """
.formatted(redirectUrl, redirectUrl, redirectUrl); .formatted(redirectUrl, redirectUrl, redirectUrl);
return Response.ok(html).type("text/html").build(); return Response.ok(html).type("text/html").build();
} catch (Exception e) { } catch (Exception e) {
// En cas d'erreur, retourner une page d'erreur simple // En cas d'erreur, retourner une page d'erreur simple
String errorHtml = String errorHtml =
""" """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head><title>Erreur d'authentification</title></head> <head><title>Erreur d'authentification</title></head>
<body style="font-family: Arial; text-align: center; padding: 50px;"> <body style="font-family: Arial; text-align: center; padding: 50px;">
<h2>❌ Erreur d'authentification</h2> <h2>❌ Erreur d'authentification</h2>
<p>Une erreur s'est produite lors de la redirection.</p> <p>Une erreur s'est produite lors de la redirection.</p>
<p>Veuillez fermer cette page et réessayer.</p> <p>Veuillez fermer cette page et réessayer.</p>
</body> </body>
</html> </html>
"""; """;
return Response.status(500).entity(errorHtml).type("text/html").build(); return Response.status(500).entity(errorHtml).type("text/html").build();
} }
} }
} }

View File

@@ -1,250 +1,250 @@
package dev.lions.unionflow.server; package dev.lions.unionflow.server;
import io.quarkus.runtime.Quarkus; import io.quarkus.runtime.Quarkus;
import io.quarkus.runtime.QuarkusApplication; import io.quarkus.runtime.QuarkusApplication;
import io.quarkus.runtime.annotations.QuarkusMain; import io.quarkus.runtime.annotations.QuarkusMain;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
/** /**
* Point d'entrée principal du serveur UnionFlow. * Point d'entrée principal du serveur UnionFlow.
* *
* <p><b>UnionFlow</b> est une plateforme de gestion associative multi-tenant * <p><b>UnionFlow</b> est une plateforme de gestion associative multi-tenant
* destinée aux organisations de solidarité (associations, mutuelles, coopératives, * destinée aux organisations de solidarité (associations, mutuelles, coopératives,
* tontines, ONG) en Afrique de l'Ouest. * tontines, ONG) en Afrique de l'Ouest.
* *
* <h2>Architecture</h2> * <h2>Architecture</h2>
* <ul> * <ul>
* <li><b>Backend</b> : Quarkus 3.15.1, Java 17, Hibernate Panache</li> * <li><b>Backend</b> : Quarkus 3.15.1, Java 17, Hibernate Panache</li>
* <li><b>Base de données</b> : PostgreSQL 15 avec Flyway</li> * <li><b>Base de données</b> : PostgreSQL 15 avec Flyway</li>
* <li><b>Authentification</b> : Keycloak 23 (OIDC/OAuth2)</li> * <li><b>Authentification</b> : Keycloak 23 (OIDC/OAuth2)</li>
* <li><b>API</b> : REST (JAX-RS) + WebSocket (temps réel)</li> * <li><b>API</b> : REST (JAX-RS) + WebSocket (temps réel)</li>
* <li><b>Paiements</b> : Wave Money CI (Mobile Money)</li> * <li><b>Paiements</b> : Wave Money CI (Mobile Money)</li>
* </ul> * </ul>
* *
* <h2>Modules fonctionnels</h2> * <h2>Modules fonctionnels</h2>
* <ul> * <ul>
* <li><b>Organisations</b> — Hiérarchie multi-niveau, types paramétrables, * <li><b>Organisations</b> — Hiérarchie multi-niveau, types paramétrables,
* modules activables par organisation</li> * modules activables par organisation</li>
* <li><b>Membres</b> — Adhésion, profils, rôles/permissions RBAC, * <li><b>Membres</b> — Adhésion, profils, rôles/permissions RBAC,
* synchronisation bidirectionnelle avec Keycloak</li> * synchronisation bidirectionnelle avec Keycloak</li>
* <li><b>Cotisations &amp; Paiements</b> — Campagnes récurrentes, * <li><b>Cotisations &amp; Paiements</b> — Campagnes récurrentes,
* ventilation polymorphique, intégration Wave Money</li> * ventilation polymorphique, intégration Wave Money</li>
* <li><b>Événements</b> — Création, inscriptions, gestion des présences, * <li><b>Événements</b> — Création, inscriptions, gestion des présences,
* géolocalisation</li> * géolocalisation</li>
* <li><b>Solidarité</b> — Demandes d'aide, propositions, matching intelligent, * <li><b>Solidarité</b> — Demandes d'aide, propositions, matching intelligent,
* workflow de validation multi-étapes</li> * workflow de validation multi-étapes</li>
* <li><b>Mutuelles</b> — Épargne, crédit, tontines, suivi des tours</li> * <li><b>Mutuelles</b> — Épargne, crédit, tontines, suivi des tours</li>
* <li><b>Comptabilité</b> — Plan comptable SYSCOHADA, journaux, * <li><b>Comptabilité</b> — Plan comptable SYSCOHADA, journaux,
* écritures automatiques, balance, grand livre</li> * écritures automatiques, balance, grand livre</li>
* <li><b>Documents</b> — Gestion polymorphique de pièces jointes * <li><b>Documents</b> — Gestion polymorphique de pièces jointes
* (stockage local + métadonnées)</li> * (stockage local + métadonnées)</li>
* <li><b>Notifications</b> — Templates multicanaux (email, SMS, push), * <li><b>Notifications</b> — Templates multicanaux (email, SMS, push),
* préférences utilisateur, historique persistant</li> * préférences utilisateur, historique persistant</li>
* <li><b>Analytics &amp; Dashboard</b> — KPIs temps réel via WebSocket, * <li><b>Analytics &amp; Dashboard</b> — KPIs temps réel via WebSocket,
* métriques d'activité, tendances, rapports PDF</li> * métriques d'activité, tendances, rapports PDF</li>
* <li><b>Administration</b> — Audit trail complet, tickets support, * <li><b>Administration</b> — Audit trail complet, tickets support,
* suggestions utilisateurs, favoris</li> * suggestions utilisateurs, favoris</li>
* <li><b>SaaS Multi-tenant</b> — Formules d'abonnement flexibles, * <li><b>SaaS Multi-tenant</b> — Formules d'abonnement flexibles,
* souscriptions par organisation, facturation</li> * souscriptions par organisation, facturation</li>
* <li><b>Configuration dynamique</b> — Table {@code configurations}, * <li><b>Configuration dynamique</b> — Table {@code configurations},
* pas de hardcoding, paramétrage par organisation</li> * pas de hardcoding, paramétrage par organisation</li>
* <li><b>Données de référence</b> — Table {@code types_reference} * <li><b>Données de référence</b> — Table {@code types_reference}
* entièrement CRUD-able (évite les enums Java)</li> * entièrement CRUD-able (évite les enums Java)</li>
* </ul> * </ul>
* *
* <h2>Inventaire technique</h2> * <h2>Inventaire technique</h2>
* <ul> * <ul>
* <li><b>60 entités JPA</b> — {@code BaseEntity} + {@code AuditEntityListener} * <li><b>60 entités JPA</b> — {@code BaseEntity} + {@code AuditEntityListener}
* pour audit automatique</li> * pour audit automatique</li>
* <li><b>46 services CDI</b> — Logique métier transactionnelle</li> * <li><b>46 services CDI</b> — Logique métier transactionnelle</li>
* <li><b>37 endpoints REST</b> — API JAX-RS avec validation Bean Validation</li> * <li><b>37 endpoints REST</b> — API JAX-RS avec validation Bean Validation</li>
* <li><b>49 repositories</b> — Hibernate Panache pour accès données</li> * <li><b>49 repositories</b> — Hibernate Panache pour accès données</li>
* <li><b>Migrations Flyway</b> — V1.0 --> V3.0 (schéma complet 60 tables)</li> * <li><b>Migrations Flyway</b> — V1.0 --> V3.0 (schéma complet 60 tables)</li>
* <li><b>Tests</b> — 1127 tests unitaires et d'intégration Quarkus</li> * <li><b>Tests</b> — 1127 tests unitaires et d'intégration Quarkus</li>
* <li><b>Couverture</b> — JaCoCo 40% minimum (cible 60%)</li> * <li><b>Couverture</b> — JaCoCo 40% minimum (cible 60%)</li>
* </ul> * </ul>
* *
* <h2>Patterns et Best Practices</h2> * <h2>Patterns et Best Practices</h2>
* <ul> * <ul>
* <li><b>Clean Architecture</b> — Séparation API/Impl/Entity</li> * <li><b>Clean Architecture</b> — Séparation API/Impl/Entity</li>
* <li><b>DTO Pattern</b> — Request/Response distincts (142 DTOs dans server-api)</li> * <li><b>DTO Pattern</b> — Request/Response distincts (142 DTOs dans server-api)</li>
* <li><b>Repository Pattern</b> — Abstraction accès données</li> * <li><b>Repository Pattern</b> — Abstraction accès données</li>
* <li><b>Service Layer</b> — Transactionnel, validation métier</li> * <li><b>Service Layer</b> — Transactionnel, validation métier</li>
* <li><b>Audit automatique</b> — EntityListener JPA pour traçabilité complète</li> * <li><b>Audit automatique</b> — EntityListener JPA pour traçabilité complète</li>
* <li><b>Soft Delete</b> — Champ {@code actif} sur toutes les entités</li> * <li><b>Soft Delete</b> — Champ {@code actif} sur toutes les entités</li>
* <li><b>Optimistic Locking</b> — Champ {@code version} pour concurrence</li> * <li><b>Optimistic Locking</b> — Champ {@code version} pour concurrence</li>
* <li><b>Configuration externalisée</b> — MicroProfile Config, pas de hardcoding</li> * <li><b>Configuration externalisée</b> — MicroProfile Config, pas de hardcoding</li>
* </ul> * </ul>
* *
* <h2>Sécurité</h2> * <h2>Sécurité</h2>
* <ul> * <ul>
* <li>OIDC avec Keycloak (realm: unionflow)</li> * <li>OIDC avec Keycloak (realm: unionflow)</li>
* <li>JWT signature côté backend (HMAC-SHA256)</li> * <li>JWT signature côté backend (HMAC-SHA256)</li>
* <li>RBAC avec rôles: SUPER_ADMIN, ADMIN_ORGANISATION, MEMBRE</li> * <li>RBAC avec rôles: SUPER_ADMIN, ADMIN_ORGANISATION, MEMBRE</li>
* <li>Permissions granulaires par module</li> * <li>Permissions granulaires par module</li>
* <li>CORS configuré pour client web</li> * <li>CORS configuré pour client web</li>
* <li>HTTPS obligatoire en production</li> * <li>HTTPS obligatoire en production</li>
* </ul> * </ul>
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0.0 * @version 3.0.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@QuarkusMain @QuarkusMain
@ApplicationScoped @ApplicationScoped
public class UnionFlowServerApplication implements QuarkusApplication { public class UnionFlowServerApplication implements QuarkusApplication {
private static final Logger LOG = Logger.getLogger(UnionFlowServerApplication.class); private static final Logger LOG = Logger.getLogger(UnionFlowServerApplication.class);
/** Port HTTP configuré (défaut: 8080). */ /** Port HTTP configuré (défaut: 8080). */
@ConfigProperty(name = "quarkus.http.port", defaultValue = "8080") @ConfigProperty(name = "quarkus.http.port", defaultValue = "8080")
int httpPort; int httpPort;
/** Host HTTP configuré (défaut: 0.0.0.0). */ /** Host HTTP configuré (défaut: 0.0.0.0). */
@ConfigProperty(name = "quarkus.http.host", defaultValue = "0.0.0.0") @ConfigProperty(name = "quarkus.http.host", defaultValue = "0.0.0.0")
String httpHost; String httpHost;
/** Nom de l'application. */ /** Nom de l'application. */
@ConfigProperty(name = "quarkus.application.name", defaultValue = "unionflow-server") @ConfigProperty(name = "quarkus.application.name", defaultValue = "unionflow-server")
String applicationName; String applicationName;
/** Version de l'application. */ /** Version de l'application. */
@ConfigProperty(name = "quarkus.application.version", defaultValue = "3.0.0") @ConfigProperty(name = "quarkus.application.version", defaultValue = "3.0.0")
String applicationVersion; String applicationVersion;
/** Profil actif (dev, test, prod). */ /** Profil actif (dev, test, prod). */
@ConfigProperty(name = "quarkus.profile") @ConfigProperty(name = "quarkus.profile")
String activeProfile; String activeProfile;
/** Version de Quarkus. */ /** Version de Quarkus. */
@ConfigProperty(name = "quarkus.platform.version", defaultValue = "3.15.1") @ConfigProperty(name = "quarkus.platform.version", defaultValue = "3.15.1")
String quarkusVersion; String quarkusVersion;
/** /**
* Point d'entrée JVM. * Point d'entrée JVM.
* *
* <p>Lance l'application Quarkus en mode bloquant. * <p>Lance l'application Quarkus en mode bloquant.
* En mode natif, cette méthode démarre instantanément (&lt; 50ms). * En mode natif, cette méthode démarre instantanément (&lt; 50ms).
* *
* @param args Arguments de ligne de commande (non utilisés) * @param args Arguments de ligne de commande (non utilisés)
*/ */
public static void main(String... args) { public static void main(String... args) {
Quarkus.run(UnionFlowServerApplication.class, args); Quarkus.run(UnionFlowServerApplication.class, args);
} }
/** /**
* Méthode de démarrage de l'application. * Méthode de démarrage de l'application.
* *
* <p>Affiche les informations de démarrage (URLs, configuration) * <p>Affiche les informations de démarrage (URLs, configuration)
* puis attend le signal d'arrêt (SIGTERM, SIGINT). * puis attend le signal d'arrêt (SIGTERM, SIGINT).
* *
* @param args Arguments passés depuis main() * @param args Arguments passés depuis main()
* @return Code de sortie (0 = succès) * @return Code de sortie (0 = succès)
* @throws Exception Si erreur fatale au démarrage * @throws Exception Si erreur fatale au démarrage
*/ */
@Override @Override
public int run(String... args) throws Exception { public int run(String... args) throws Exception {
logStartupBanner(); logStartupBanner();
logConfiguration(); logConfiguration();
logEndpoints(); logEndpoints();
logArchitecture(); logArchitecture();
LOG.info("UnionFlow Server prêt à recevoir des requêtes"); LOG.info("UnionFlow Server prêt à recevoir des requêtes");
LOG.info("Appuyez sur Ctrl+C pour arrêter"); LOG.info("Appuyez sur Ctrl+C pour arrêter");
// Attend le signal d'arrêt (bloquant) // Attend le signal d'arrêt (bloquant)
Quarkus.waitForExit(); Quarkus.waitForExit();
LOG.info("UnionFlow Server arrêté proprement"); LOG.info("UnionFlow Server arrêté proprement");
return 0; return 0;
} }
/** /**
* Affiche la bannière ASCII de démarrage. * Affiche la bannière ASCII de démarrage.
*/ */
private void logStartupBanner() { private void logStartupBanner() {
LOG.info("----------------------------------------------------------"); LOG.info("----------------------------------------------------------");
LOG.info("- -"); LOG.info("- -");
LOG.info("- UNIONFLOW SERVER v" + applicationVersion + " "); LOG.info("- UNIONFLOW SERVER v" + applicationVersion + " ");
LOG.info("- Plateforme de Gestion Associative Multi-Tenant -"); LOG.info("- Plateforme de Gestion Associative Multi-Tenant -");
LOG.info("- -"); LOG.info("- -");
LOG.info("----------------------------------------------------------"); LOG.info("----------------------------------------------------------");
} }
/** /**
* Affiche la configuration active. * Affiche la configuration active.
*/ */
private void logConfiguration() { private void logConfiguration() {
LOG.infof("Profil : %s", activeProfile); LOG.infof("Profil : %s", activeProfile);
LOG.infof("Application : %s v%s", applicationName, applicationVersion); LOG.infof("Application : %s v%s", applicationName, applicationVersion);
LOG.infof("Java : %s", System.getProperty("java.version")); LOG.infof("Java : %s", System.getProperty("java.version"));
LOG.infof("Quarkus : %s", quarkusVersion); LOG.infof("Quarkus : %s", quarkusVersion);
} }
/** /**
* Affiche les URLs des endpoints principaux. * Affiche les URLs des endpoints principaux.
*/ */
private void logEndpoints() { private void logEndpoints() {
String baseUrl = buildBaseUrl(); String baseUrl = buildBaseUrl();
LOG.info("--------------------------------------------------------------"); LOG.info("--------------------------------------------------------------");
LOG.info("📡 Endpoints disponibles:"); LOG.info("📡 Endpoints disponibles:");
LOG.infof(" - API REST --> %s/api", baseUrl); LOG.infof(" - API REST --> %s/api", baseUrl);
LOG.infof(" - Swagger UI --> %s/q/swagger-ui", baseUrl); LOG.infof(" - Swagger UI --> %s/q/swagger-ui", baseUrl);
LOG.infof(" - Health Check --> %s/q/health", baseUrl); LOG.infof(" - Health Check --> %s/q/health", baseUrl);
LOG.infof(" - Metrics --> %s/q/metrics", baseUrl); LOG.infof(" - Metrics --> %s/q/metrics", baseUrl);
LOG.infof(" - OpenAPI --> %s/q/openapi", baseUrl); LOG.infof(" - OpenAPI --> %s/q/openapi", baseUrl);
if ("dev".equals(activeProfile)) { if ("dev".equals(activeProfile)) {
LOG.infof(" - Dev UI --> %s/q/dev", baseUrl); LOG.infof(" - Dev UI --> %s/q/dev", baseUrl);
LOG.infof(" - H2 Console --> %s/q/dev/io.quarkus.quarkus-datasource/datasources", baseUrl); LOG.infof(" - H2 Console --> %s/q/dev/io.quarkus.quarkus-datasource/datasources", baseUrl);
} }
LOG.info("--------------------------------------------------------------"); LOG.info("--------------------------------------------------------------");
} }
/** /**
* Affiche l'inventaire de l'architecture. * Affiche l'inventaire de l'architecture.
*/ */
private void logArchitecture() { private void logArchitecture() {
LOG.info(" Architecture:"); LOG.info(" Architecture:");
LOG.info(" - 60 Entités JPA"); LOG.info(" - 60 Entités JPA");
LOG.info(" - 46 Services CDI"); LOG.info(" - 46 Services CDI");
LOG.info(" - 37 Endpoints REST"); LOG.info(" - 37 Endpoints REST");
LOG.info(" - 49 Repositories Panache"); LOG.info(" - 49 Repositories Panache");
LOG.info(" - 142 DTOs (Request/Response)"); LOG.info(" - 142 DTOs (Request/Response)");
LOG.info(" - 1127 Tests automatisés"); LOG.info(" - 1127 Tests automatisés");
LOG.info("--------------------------------------------------------------"); LOG.info("--------------------------------------------------------------");
} }
/** /**
* Retourne la valeur de la variable d'environnement UNIONFLOW_DOMAIN. * Retourne la valeur de la variable d'environnement UNIONFLOW_DOMAIN.
* Méthode protégée pour permettre la substitution en tests. * Méthode protégée pour permettre la substitution en tests.
* *
* @return valeur de UNIONFLOW_DOMAIN, ou null si non définie * @return valeur de UNIONFLOW_DOMAIN, ou null si non définie
*/ */
protected String getUnionflowDomain() { protected String getUnionflowDomain() {
return System.getenv("UNIONFLOW_DOMAIN"); return System.getenv("UNIONFLOW_DOMAIN");
} }
/** /**
* Construit l'URL de base de l'application. * Construit l'URL de base de l'application.
* *
* @return URL complète (ex: http://localhost:8080) * @return URL complète (ex: http://localhost:8080)
*/ */
String buildBaseUrl() { String buildBaseUrl() {
// En production, utiliser le nom de domaine configuré // En production, utiliser le nom de domaine configuré
if ("prod".equals(activeProfile)) { if ("prod".equals(activeProfile)) {
String domain = getUnionflowDomain(); String domain = getUnionflowDomain();
if (domain != null && !domain.isEmpty()) { if (domain != null && !domain.isEmpty()) {
return "https://" + domain; return "https://" + domain;
} }
} }
// En dev/test, utiliser localhost // En dev/test, utiliser localhost
String host = "0.0.0.0".equals(httpHost) ? "localhost" : httpHost; String host = "0.0.0.0".equals(httpHost) ? "localhost" : httpHost;
return String.format("http://%s:%d", host, httpPort); return String.format("http://%s:%d", host, httpPort);
} }
} }

View File

@@ -1,48 +1,48 @@
package dev.lions.unionflow.server.client; package dev.lions.unionflow.server.client;
import io.quarkus.oidc.client.NamedOidcClient; import io.quarkus.oidc.client.NamedOidcClient;
import io.quarkus.oidc.client.OidcClient; import io.quarkus.oidc.client.OidcClient;
import io.quarkus.oidc.client.Tokens; import io.quarkus.oidc.client.Tokens;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory; import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
/** /**
* Injecte le token du service account "admin-service" (client credentials grant) * Injecte le token du service account "admin-service" (client credentials grant)
* dans tous les appels faits via {@link AdminUserServiceClient} et {@link AdminRoleServiceClient}. * dans tous les appels faits via {@link AdminUserServiceClient} et {@link AdminRoleServiceClient}.
* *
* <p>Utilise directement l'API {@link OidcClient} pour récupérer/rafraîchir le token. * <p>Utilise directement l'API {@link OidcClient} pour récupérer/rafraîchir le token.
* Cette approche explicite évite toute ambiguïté avec {@code @OidcClientFilter} quand * Cette approche explicite évite toute ambiguïté avec {@code @OidcClientFilter} quand
* plusieurs interfaces REST partagent le même configKey. * plusieurs interfaces REST partagent le même configKey.
*/ */
@ApplicationScoped @ApplicationScoped
public class AdminServiceTokenHeadersFactory implements ClientHeadersFactory { public class AdminServiceTokenHeadersFactory implements ClientHeadersFactory {
private static final Logger LOG = Logger.getLogger(AdminServiceTokenHeadersFactory.class); private static final Logger LOG = Logger.getLogger(AdminServiceTokenHeadersFactory.class);
@Inject @Inject
@NamedOidcClient("admin-service") @NamedOidcClient("admin-service")
OidcClient adminOidcClient; OidcClient adminOidcClient;
@Override @Override
public MultivaluedMap<String, String> update( public MultivaluedMap<String, String> update(
MultivaluedMap<String, String> incomingHeaders, MultivaluedMap<String, String> incomingHeaders,
MultivaluedMap<String, String> clientOutgoingHeaders) { MultivaluedMap<String, String> clientOutgoingHeaders) {
MultivaluedMap<String, String> result = new MultivaluedHashMap<>(); MultivaluedMap<String, String> result = new MultivaluedHashMap<>();
try { try {
Tokens tokens = adminOidcClient.getTokens().await().indefinitely(); Tokens tokens = adminOidcClient.getTokens().await().indefinitely();
result.add("Authorization", "Bearer " + tokens.getAccessToken()); result.add("Authorization", "Bearer " + tokens.getAccessToken());
LOG.debugf("Token service account injecté pour admin-service (longueur: %d)", LOG.debugf("Token service account injecté pour admin-service (longueur: %d)",
tokens.getAccessToken().length()); tokens.getAccessToken().length());
} catch (Exception e) { } catch (Exception e) {
LOG.errorf("Impossible d'obtenir le token service account 'admin-service': %s", e.getMessage()); LOG.errorf("Impossible d'obtenir le token service account 'admin-service': %s", e.getMessage());
throw new jakarta.ws.rs.ServiceUnavailableException( throw new jakarta.ws.rs.ServiceUnavailableException(
"Service d'authentification interne indisponible: " + e.getMessage()); "Service d'authentification interne indisponible: " + e.getMessage());
} }
return result; return result;
} }
} }

View File

@@ -1,71 +1,77 @@
package dev.lions.unionflow.server.client; package dev.lions.unionflow.server.client;
import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal; import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.client.ClientRequestContext; import jakarta.inject.Inject;
import jakarta.ws.rs.client.ClientRequestFilter; import jakarta.ws.rs.client.ClientRequestContext;
import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.client.ClientRequestFilter;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.core.HttpHeaders;
import org.eclipse.microprofile.jwt.JsonWebToken; import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import java.io.IOException; import java.io.IOException;
/** /**
* Filtre REST Client qui propage le token JWT de la requête entrante. * Filtre REST Client qui propage le token JWT de la requête entrante.
* *
* <p>NE PAS annoter avec {@code @Provider} — cela l'enregistrerait GLOBALEMENT * <p>NE PAS annoter avec {@code @Provider} — cela l'enregistrerait GLOBALEMENT
* sur tous les REST clients, y compris AdminUserServiceClient/AdminRoleServiceClient * sur tous les REST clients, y compris AdminUserServiceClient/AdminRoleServiceClient
* qui utilisent AdminServiceTokenHeadersFactory (service account). Le filtre global * qui utilisent AdminServiceTokenHeadersFactory (service account). Le filtre global
* écraserait le token de service account avec le JWT utilisateur → 401 sur LUM. * écraserait le token de service account avec le JWT utilisateur → 401 sur LUM.
* *
* <p>La propagation JWT est assurée par {@link OidcTokenPropagationHeadersFactory} * <p>{@code @ApplicationScoped} est requis pour la découverte CDI (tests {@code @QuarkusTest}
* sur les clients qui en ont besoin ({@code @RegisterClientHeaders}). * qui {@code @Inject} le filter). Cela ne provoque PAS d'enregistrement automatique JAX-RS
*/ * — l'opt-in se fait via {@code @RegisterProvider(JwtPropagationFilter.class)} sur les
public class JwtPropagationFilter implements ClientRequestFilter { * REST clients qui le souhaitent.
*
private static final Logger LOG = Logger.getLogger(JwtPropagationFilter.class); * <p>La propagation JWT par défaut est assurée par {@link OidcTokenPropagationHeadersFactory}
* sur les clients qui en ont besoin ({@code @RegisterClientHeaders}).
@Inject */
SecurityIdentity securityIdentity; @ApplicationScoped
public class JwtPropagationFilter implements ClientRequestFilter {
@Override
public void filter(ClientRequestContext requestContext) throws IOException { private static final Logger LOG = Logger.getLogger(JwtPropagationFilter.class);
if (securityIdentity != null && !securityIdentity.isAnonymous()) {
// Récupérer le token JWT depuis le principal @Inject
if (securityIdentity.getPrincipal() instanceof OidcJwtCallerPrincipal) { SecurityIdentity securityIdentity;
OidcJwtCallerPrincipal principal = (OidcJwtCallerPrincipal) securityIdentity.getPrincipal();
String token = principal.getRawToken(); @Override
public void filter(ClientRequestContext requestContext) throws IOException {
if (token != null && !token.isBlank()) { if (securityIdentity != null && !securityIdentity.isAnonymous()) {
requestContext.getHeaders().putSingle( // Récupérer le token JWT depuis le principal
HttpHeaders.AUTHORIZATION, if (securityIdentity.getPrincipal() instanceof OidcJwtCallerPrincipal) {
"Bearer " + token OidcJwtCallerPrincipal principal = (OidcJwtCallerPrincipal) securityIdentity.getPrincipal();
); String token = principal.getRawToken();
LOG.debugf("Token JWT propagé vers %s", requestContext.getUri());
} else { if (token != null && !token.isBlank()) {
LOG.warnf("Token JWT vide pour %s", requestContext.getUri()); requestContext.getHeaders().putSingle(
} HttpHeaders.AUTHORIZATION,
} else if (securityIdentity.getPrincipal() instanceof JsonWebToken) { "Bearer " + token
JsonWebToken jwt = (JsonWebToken) securityIdentity.getPrincipal(); );
String token = jwt.getRawToken(); LOG.debugf("Token JWT propagé vers %s", requestContext.getUri());
} else {
if (token != null && !token.isBlank()) { LOG.warnf("Token JWT vide pour %s", requestContext.getUri());
requestContext.getHeaders().putSingle( }
HttpHeaders.AUTHORIZATION, } else if (securityIdentity.getPrincipal() instanceof JsonWebToken) {
"Bearer " + token JsonWebToken jwt = (JsonWebToken) securityIdentity.getPrincipal();
); String token = jwt.getRawToken();
LOG.debugf("Token JWT propagé vers %s", requestContext.getUri());
} if (token != null && !token.isBlank()) {
} else { requestContext.getHeaders().putSingle(
LOG.warnf("Principal n'est pas un JWT pour %s (type: %s)", HttpHeaders.AUTHORIZATION,
requestContext.getUri(), "Bearer " + token
securityIdentity.getPrincipal().getClass().getName()); );
} LOG.debugf("Token JWT propagé vers %s", requestContext.getUri());
} else { }
LOG.warnf("Pas de SecurityIdentity ou utilisateur anonyme pour %s", } else {
requestContext.getUri()); LOG.warnf("Principal n'est pas un JWT pour %s (type: %s)",
} requestContext.getUri(),
} securityIdentity.getPrincipal().getClass().getName());
} }
} else {
LOG.warnf("Pas de SecurityIdentity ou utilisateur anonyme pour %s",
requestContext.getUri());
}
}
}

View File

@@ -1,72 +1,72 @@
package dev.lions.unionflow.server.client; package dev.lions.unionflow.server.client;
import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal; import io.quarkus.oidc.runtime.OidcJwtCallerPrincipal;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject; import jakarta.inject.Inject;
import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedHashMap;
import jakarta.ws.rs.core.MultivaluedMap; import jakarta.ws.rs.core.MultivaluedMap;
import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory; import org.eclipse.microprofile.rest.client.ext.ClientHeadersFactory;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
/** /**
* Factory pour propager automatiquement le token JWT OIDC * Factory pour propager automatiquement le token JWT OIDC
* vers les appels REST Client (compatible Quarkus REST). * vers les appels REST Client (compatible Quarkus REST).
* *
* Stratégie : copier le header Authorization de la requête entrante * Stratégie : copier le header Authorization de la requête entrante
* ou récupérer le token depuis SecurityIdentity si disponible. * ou récupérer le token depuis SecurityIdentity si disponible.
*/ */
@ApplicationScoped @ApplicationScoped
public class OidcTokenPropagationHeadersFactory implements ClientHeadersFactory { public class OidcTokenPropagationHeadersFactory implements ClientHeadersFactory {
private static final Logger LOG = Logger.getLogger(OidcTokenPropagationHeadersFactory.class); private static final Logger LOG = Logger.getLogger(OidcTokenPropagationHeadersFactory.class);
@Inject @Inject
Instance<SecurityIdentity> securityIdentity; Instance<SecurityIdentity> securityIdentity;
@Override @Override
public MultivaluedMap<String, String> update( public MultivaluedMap<String, String> update(
MultivaluedMap<String, String> incomingHeaders, MultivaluedMap<String, String> incomingHeaders,
MultivaluedMap<String, String> clientOutgoingHeaders) { MultivaluedMap<String, String> clientOutgoingHeaders) {
MultivaluedMap<String, String> result = new MultivaluedHashMap<>(); MultivaluedMap<String, String> result = new MultivaluedHashMap<>();
// STRATÉGIE 1 : Copier directement le header Authorization de la requête entrante // STRATÉGIE 1 : Copier directement le header Authorization de la requête entrante
if (incomingHeaders != null && incomingHeaders.containsKey("Authorization")) { if (incomingHeaders != null && incomingHeaders.containsKey("Authorization")) {
String authHeader = incomingHeaders.getFirst("Authorization"); String authHeader = incomingHeaders.getFirst("Authorization");
if (authHeader != null && !authHeader.isBlank()) { if (authHeader != null && !authHeader.isBlank()) {
result.add("Authorization", authHeader); result.add("Authorization", authHeader);
LOG.infof("✅ Token JWT propagé depuis incomingHeaders (longueur: %d)", authHeader.length()); LOG.infof("✅ Token JWT propagé depuis incomingHeaders (longueur: %d)", authHeader.length());
return result; return result;
} }
} }
// STRATÉGIE 2 : Récupérer depuis SecurityIdentity // STRATÉGIE 2 : Récupérer depuis SecurityIdentity
// En contexte CDI, securityIdentity.isResolvable() est toujours true. // En contexte CDI, securityIdentity.isResolvable() est toujours true.
SecurityIdentity identity = securityIdentity.get(); SecurityIdentity identity = securityIdentity.get();
if (identity != null && !identity.isAnonymous()) { if (identity != null && !identity.isAnonymous()) {
if (identity.getPrincipal() instanceof OidcJwtCallerPrincipal) { if (identity.getPrincipal() instanceof OidcJwtCallerPrincipal) {
OidcJwtCallerPrincipal principal = (OidcJwtCallerPrincipal) identity.getPrincipal(); OidcJwtCallerPrincipal principal = (OidcJwtCallerPrincipal) identity.getPrincipal();
String token = principal.getRawToken(); String token = principal.getRawToken();
if (token != null && !token.isBlank()) { if (token != null && !token.isBlank()) {
result.add("Authorization", "Bearer " + token); result.add("Authorization", "Bearer " + token);
LOG.infof("✅ Token JWT propagé depuis SecurityIdentity (longueur: %d)", token.length()); LOG.infof("✅ Token JWT propagé depuis SecurityIdentity (longueur: %d)", token.length());
return result; return result;
} else { } else {
LOG.warnf("⚠️ Token JWT vide dans SecurityIdentity"); LOG.warnf("⚠️ Token JWT vide dans SecurityIdentity");
} }
} else { } else {
LOG.warnf("⚠️ Principal n'est pas un OidcJwtCallerPrincipal (type: %s)", LOG.warnf("⚠️ Principal n'est pas un OidcJwtCallerPrincipal (type: %s)",
identity.getPrincipal() != null ? identity.getPrincipal().getClass().getName() : "null"); identity.getPrincipal() != null ? identity.getPrincipal().getClass().getName() : "null");
} }
} else { } else {
LOG.warnf("⚠️ SecurityIdentity null ou utilisateur anonyme"); LOG.warnf("⚠️ SecurityIdentity null ou utilisateur anonyme");
} }
LOG.errorf("❌ Impossible de propager le token JWT - aucune stratégie n'a fonctionné"); LOG.errorf("❌ Impossible de propager le token JWT - aucune stratégie n'a fonctionné");
return result; return result;
} }
} }

View File

@@ -1,57 +1,57 @@
package dev.lions.unionflow.server.client; package dev.lions.unionflow.server.client;
import dev.lions.user.manager.dto.role.RoleDTO; import dev.lions.user.manager.dto.role.RoleDTO;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import java.util.List; import java.util.List;
/** /**
* REST Client pour l'API rôles de lions-user-manager (Keycloak). * REST Client pour l'API rôles de lions-user-manager (Keycloak).
* Même base URL que UserServiceClient (configKey = lions-user-manager-api). * Même base URL que UserServiceClient (configKey = lions-user-manager-api).
*/ */
@Path("/api/roles") @Path("/api/roles")
@RegisterRestClient(configKey = "lions-user-manager-api") @RegisterRestClient(configKey = "lions-user-manager-api")
@RegisterClientHeaders(OidcTokenPropagationHeadersFactory.class) @RegisterClientHeaders(OidcTokenPropagationHeadersFactory.class)
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public interface RoleServiceClient { public interface RoleServiceClient {
@GET @GET
@Path("/realm") @Path("/realm")
List<RoleDTO> getRealmRoles(@QueryParam("realm") String realmName); List<RoleDTO> getRealmRoles(@QueryParam("realm") String realmName);
@GET @GET
@Path("/user/realm/{userId}") @Path("/user/realm/{userId}")
List<RoleDTO> getUserRealmRoles( List<RoleDTO> getUserRealmRoles(
@PathParam("userId") String userId, @PathParam("userId") String userId,
@QueryParam("realm") String realmName @QueryParam("realm") String realmName
); );
@POST @POST
@Path("/assign/realm/{userId}") @Path("/assign/realm/{userId}")
void assignRealmRoles( void assignRealmRoles(
@PathParam("userId") String userId, @PathParam("userId") String userId,
@QueryParam("realm") String realmName, @QueryParam("realm") String realmName,
RoleNamesRequest request RoleNamesRequest request
); );
@POST @POST
@Path("/revoke/realm/{userId}") @Path("/revoke/realm/{userId}")
void revokeRealmRoles( void revokeRealmRoles(
@PathParam("userId") String userId, @PathParam("userId") String userId,
@QueryParam("realm") String realmName, @QueryParam("realm") String realmName,
RoleNamesRequest request RoleNamesRequest request
); );
/** Corps de requête pour assign/revoke (compatible lions-user-manager). */ /** Corps de requête pour assign/revoke (compatible lions-user-manager). */
class RoleNamesRequest { class RoleNamesRequest {
public List<String> roleNames; public List<String> roleNames;
public RoleNamesRequest() {} public RoleNamesRequest() {}
public RoleNamesRequest(List<String> roleNames) { this.roleNames = roleNames; } public RoleNamesRequest(List<String> roleNames) { this.roleNames = roleNames; }
public List<String> getRoleNames() { return roleNames; } public List<String> getRoleNames() { return roleNames; }
public void setRoleNames(List<String> roleNames) { this.roleNames = roleNames; } public void setRoleNames(List<String> roleNames) { this.roleNames = roleNames; }
} }
} }

View File

@@ -1,77 +1,77 @@
package dev.lions.unionflow.server.client; package dev.lions.unionflow.server.client;
import dev.lions.user.manager.dto.user.UserDTO; import dev.lions.user.manager.dto.user.UserDTO;
import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO; import dev.lions.user.manager.dto.user.UserSearchCriteriaDTO;
import dev.lions.user.manager.dto.user.UserSearchResultDTO; import dev.lions.user.manager.dto.user.UserSearchResultDTO;
import jakarta.ws.rs.*; import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders; import org.eclipse.microprofile.rest.client.annotation.RegisterClientHeaders;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
/** /**
* REST Client pour le service de gestion des utilisateurs Keycloak * REST Client pour le service de gestion des utilisateurs Keycloak
* via lions-user-manager API * via lions-user-manager API
* *
* Configuration dans application.properties: * Configuration dans application.properties:
* quarkus.rest-client.lions-user-manager-api.url=http://localhost:8081 * quarkus.rest-client.lions-user-manager-api.url=http://localhost:8081
*/ */
@Path("/api/users") @Path("/api/users")
@RegisterRestClient(configKey = "lions-user-manager-api") @RegisterRestClient(configKey = "lions-user-manager-api")
@RegisterClientHeaders(OidcTokenPropagationHeadersFactory.class) @RegisterClientHeaders(OidcTokenPropagationHeadersFactory.class)
@Produces(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON) @Consumes(MediaType.APPLICATION_JSON)
public interface UserServiceClient { public interface UserServiceClient {
/** /**
* Rechercher des utilisateurs selon des critères * Rechercher des utilisateurs selon des critères
*/ */
@POST @POST
@Path("/search") @Path("/search")
UserSearchResultDTO searchUsers(UserSearchCriteriaDTO criteria); UserSearchResultDTO searchUsers(UserSearchCriteriaDTO criteria);
/** /**
* Récupérer un utilisateur par ID * Récupérer un utilisateur par ID
*/ */
@GET @GET
@Path("/{userId}") @Path("/{userId}")
UserDTO getUserById( UserDTO getUserById(
@PathParam("userId") String userId, @PathParam("userId") String userId,
@QueryParam("realm") String realmName); @QueryParam("realm") String realmName);
/** /**
* Créer un nouvel utilisateur * Créer un nouvel utilisateur
*/ */
@POST @POST
UserDTO createUser( UserDTO createUser(
UserDTO user, UserDTO user,
@QueryParam("realm") String realmName); @QueryParam("realm") String realmName);
/** /**
* Mettre à jour un utilisateur * Mettre à jour un utilisateur
*/ */
@PUT @PUT
@Path("/{userId}") @Path("/{userId}")
UserDTO updateUser( UserDTO updateUser(
@PathParam("userId") String userId, @PathParam("userId") String userId,
UserDTO user, UserDTO user,
@QueryParam("realm") String realmName); @QueryParam("realm") String realmName);
/** /**
* Supprimer un utilisateur * Supprimer un utilisateur
*/ */
@DELETE @DELETE
@Path("/{userId}") @Path("/{userId}")
void deleteUser( void deleteUser(
@PathParam("userId") String userId, @PathParam("userId") String userId,
@QueryParam("realm") String realmName); @QueryParam("realm") String realmName);
/** /**
* Envoyer un email de vérification * Envoyer un email de vérification
*/ */
@POST @POST
@Path("/{userId}/send-verification-email") @Path("/{userId}/send-verification-email")
void sendVerificationEmail( void sendVerificationEmail(
@PathParam("userId") String userId, @PathParam("userId") String userId,
@QueryParam("realm") String realmName); @QueryParam("realm") String realmName);
} }

View File

@@ -1,143 +1,143 @@
package dev.lions.unionflow.server.dto; package dev.lions.unionflow.server.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import dev.lions.unionflow.server.entity.Evenement; import dev.lions.unionflow.server.entity.Evenement;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* DTO pour l'API mobile - Mapping des champs de l'entité Evenement vers le * DTO pour l'API mobile - Mapping des champs de l'entité Evenement vers le
* format attendu par * format attendu par
* l'application mobile Flutter * l'application mobile Flutter
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 2.0 * @version 2.0
* @since 2025-01-16 * @since 2025-01-16
*/ */
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class EvenementMobileDTO { public class EvenementMobileDTO {
private UUID id; private UUID id;
private String titre; private String titre;
private String description; private String description;
private LocalDateTime dateDebut; private LocalDateTime dateDebut;
private LocalDateTime dateFin; private LocalDateTime dateFin;
private String lieu; private String lieu;
private String adresse; private String adresse;
private String ville; private String ville;
private String codePostal; private String codePostal;
// Mapping: typeEvenement -> type // Mapping: typeEvenement -> type
private String type; private String type;
// Mapping: statut -> statut (OK) // Mapping: statut -> statut (OK)
private String statut; private String statut;
// Mapping: capaciteMax -> maxParticipants // Mapping: capaciteMax -> maxParticipants
private Integer maxParticipants; private Integer maxParticipants;
// Nombre de participants actuels (calculé depuis les inscriptions) // Nombre de participants actuels (calculé depuis les inscriptions)
private Integer participantsActuels; private Integer participantsActuels;
// IDs et noms pour les relations // IDs et noms pour les relations
private UUID organisateurId; private UUID organisateurId;
private String organisateurNom; private String organisateurNom;
private UUID organisationId; private UUID organisationId;
private String organisationNom; private String organisationNom;
// Priorité (à ajouter dans l'entité si nécessaire) // Priorité (à ajouter dans l'entité si nécessaire)
private String priorite; private String priorite;
// Mapping: visiblePublic -> estPublic // Mapping: visiblePublic -> estPublic
private Boolean estPublic; private Boolean estPublic;
// Mapping: inscriptionRequise -> inscriptionRequise (OK) // Mapping: inscriptionRequise -> inscriptionRequise (OK)
private Boolean inscriptionRequise; private Boolean inscriptionRequise;
// Mapping: prix -> cout // Mapping: prix -> cout
private BigDecimal cout; private BigDecimal cout;
// Devise // Devise
private String devise; private String devise;
// Tags (à implémenter si nécessaire) // Tags (à implémenter si nécessaire)
private String[] tags; private String[] tags;
// URLs // URLs
private String imageUrl; private String imageUrl;
private String documentUrl; private String documentUrl;
// Notes // Notes
private String notes; private String notes;
// Dates de création/modification // Dates de création/modification
private LocalDateTime dateCreation; private LocalDateTime dateCreation;
private LocalDateTime dateModification; private LocalDateTime dateModification;
// Actif // Actif
private Boolean actif; private Boolean actif;
/** /**
* Convertit une entité Evenement en DTO mobile * Convertit une entité Evenement en DTO mobile
* *
* @param evenement L'entité à convertir * @param evenement L'entité à convertir
* @return Le DTO mobile * @return Le DTO mobile
*/ */
public static EvenementMobileDTO fromEntity(Evenement evenement) { public static EvenementMobileDTO fromEntity(Evenement evenement) {
if (evenement == null) { if (evenement == null) {
return null; return null;
} }
return EvenementMobileDTO.builder() return EvenementMobileDTO.builder()
.id(evenement.getId()) // Utilise getId() depuis BaseEntity .id(evenement.getId()) // Utilise getId() depuis BaseEntity
.titre(evenement.getTitre()) .titre(evenement.getTitre())
.description(evenement.getDescription()) .description(evenement.getDescription())
.dateDebut(evenement.getDateDebut()) .dateDebut(evenement.getDateDebut())
.dateFin(evenement.getDateFin()) .dateFin(evenement.getDateFin())
.lieu(evenement.getLieu()) .lieu(evenement.getLieu())
.adresse(evenement.getAdresse()) .adresse(evenement.getAdresse())
.ville(null) // Pas de champ ville dans l'entité .ville(null) // Pas de champ ville dans l'entité
.codePostal(null) // Pas de champ codePostal dans l'entité .codePostal(null) // Pas de champ codePostal dans l'entité
// Mapping des enums // Mapping des enums
.type(evenement.getTypeEvenement() != null ? evenement.getTypeEvenement() : null) .type(evenement.getTypeEvenement() != null ? evenement.getTypeEvenement() : null)
.statut(evenement.getStatut() != null ? evenement.getStatut() : "PLANIFIE") .statut(evenement.getStatut() != null ? evenement.getStatut() : "PLANIFIE")
// Mapping des champs renommés // Mapping des champs renommés
.maxParticipants(evenement.getCapaciteMax()) .maxParticipants(evenement.getCapaciteMax())
.participantsActuels(evenement.getNombreInscrits()) .participantsActuels(evenement.getNombreInscrits())
// Relations (gestion sécurisée des lazy loading) // Relations (gestion sécurisée des lazy loading)
.organisateurId(evenement.getOrganisateur() != null ? evenement.getOrganisateur().getId() : null) .organisateurId(evenement.getOrganisateur() != null ? evenement.getOrganisateur().getId() : null)
.organisateurNom(evenement.getOrganisateur() != null ? evenement.getOrganisateur().getNomComplet() : null) .organisateurNom(evenement.getOrganisateur() != null ? evenement.getOrganisateur().getNomComplet() : null)
.organisationId(evenement.getOrganisation() != null ? evenement.getOrganisation().getId() : null) .organisationId(evenement.getOrganisation() != null ? evenement.getOrganisation().getId() : null)
.organisationNom(evenement.getOrganisation() != null ? evenement.getOrganisation().getNom() : null) .organisationNom(evenement.getOrganisation() != null ? evenement.getOrganisation().getNom() : null)
// Priorité (valeur par défaut) // Priorité (valeur par défaut)
.priorite("MOYENNE") .priorite("MOYENNE")
// Mapping booléens // Mapping booléens
.estPublic(evenement.getVisiblePublic()) .estPublic(evenement.getVisiblePublic())
.inscriptionRequise(evenement.getInscriptionRequise()) .inscriptionRequise(evenement.getInscriptionRequise())
// Mapping prix -> cout // Mapping prix -> cout
.cout(evenement.getPrix()) .cout(evenement.getPrix())
.devise("XOF") .devise("XOF")
// Tags vides pour l'instant // Tags vides pour l'instant
.tags(new String[] {}) .tags(new String[] {})
// URLs (à implémenter si nécessaire) // URLs (à implémenter si nécessaire)
.imageUrl(null) .imageUrl(null)
.documentUrl(null) .documentUrl(null)
// Notes // Notes
.notes(evenement.getInstructionsParticulieres()) .notes(evenement.getInstructionsParticulieres())
// Dates // Dates
.dateCreation(evenement.getDateCreation()) .dateCreation(evenement.getDateCreation())
.dateModification(evenement.getDateModification()) .dateModification(evenement.getDateModification())
// Actif // Actif
.actif(evenement.getActif()) .actif(evenement.getActif())
.build(); .build();
} }
} }

View File

@@ -1,150 +1,150 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Adresse pour la gestion des adresses des organisations, membres et * Entité Adresse pour la gestion des adresses des organisations, membres et
* événements * événements
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table(name = "adresses", indexes = { @Table(name = "adresses", indexes = {
@Index(name = "idx_adresse_ville", columnList = "ville"), @Index(name = "idx_adresse_ville", columnList = "ville"),
@Index(name = "idx_adresse_pays", columnList = "pays"), @Index(name = "idx_adresse_pays", columnList = "pays"),
@Index(name = "idx_adresse_type", columnList = "type_adresse"), @Index(name = "idx_adresse_type", columnList = "type_adresse"),
@Index(name = "idx_adresse_organisation", columnList = "organisation_id"), @Index(name = "idx_adresse_organisation", columnList = "organisation_id"),
@Index(name = "idx_adresse_membre", columnList = "membre_id"), @Index(name = "idx_adresse_membre", columnList = "membre_id"),
@Index(name = "idx_adresse_evenement", columnList = "evenement_id") @Index(name = "idx_adresse_evenement", columnList = "evenement_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Adresse extends BaseEntity { public class Adresse extends BaseEntity {
/** Type d'adresse (code depuis types_reference) */ /** Type d'adresse (code depuis types_reference) */
@Column(name = "type_adresse", nullable = false, length = 50) @Column(name = "type_adresse", nullable = false, length = 50)
private String typeAdresse; private String typeAdresse;
/** Adresse complète */ /** Adresse complète */
@Column(name = "adresse", length = 500) @Column(name = "adresse", length = 500)
private String adresse; private String adresse;
/** Complément d'adresse */ /** Complément d'adresse */
@Column(name = "complement_adresse", length = 200) @Column(name = "complement_adresse", length = 200)
private String complementAdresse; private String complementAdresse;
/** Code postal */ /** Code postal */
@Column(name = "code_postal", length = 20) @Column(name = "code_postal", length = 20)
private String codePostal; private String codePostal;
/** Ville */ /** Ville */
@Column(name = "ville", length = 100) @Column(name = "ville", length = 100)
private String ville; private String ville;
/** Région */ /** Région */
@Column(name = "region", length = 100) @Column(name = "region", length = 100)
private String region; private String region;
/** Pays */ /** Pays */
@Column(name = "pays", length = 100) @Column(name = "pays", length = 100)
private String pays; private String pays;
/** Coordonnées géographiques - Latitude */ /** Coordonnées géographiques - Latitude */
@DecimalMin(value = "-90.0", message = "La latitude doit être comprise entre -90 et 90") @DecimalMin(value = "-90.0", message = "La latitude doit être comprise entre -90 et 90")
@DecimalMax(value = "90.0", message = "La latitude doit être comprise entre -90 et 90") @DecimalMax(value = "90.0", message = "La latitude doit être comprise entre -90 et 90")
@Digits(integer = 3, fraction = 6) @Digits(integer = 3, fraction = 6)
@Column(name = "latitude", precision = 9, scale = 6) @Column(name = "latitude", precision = 9, scale = 6)
private BigDecimal latitude; private BigDecimal latitude;
/** Coordonnées géographiques - Longitude */ /** Coordonnées géographiques - Longitude */
@DecimalMin(value = "-180.0", message = "La longitude doit être comprise entre -180 et 180") @DecimalMin(value = "-180.0", message = "La longitude doit être comprise entre -180 et 180")
@DecimalMax(value = "180.0", message = "La longitude doit être comprise entre -180 et 180") @DecimalMax(value = "180.0", message = "La longitude doit être comprise entre -180 et 180")
@Digits(integer = 3, fraction = 6) @Digits(integer = 3, fraction = 6)
@Column(name = "longitude", precision = 9, scale = 6) @Column(name = "longitude", precision = 9, scale = 6)
private BigDecimal longitude; private BigDecimal longitude;
/** Adresse principale (une seule par entité) */ /** Adresse principale (une seule par entité) */
@Builder.Default @Builder.Default
@Column(name = "principale", nullable = false) @Column(name = "principale", nullable = false)
private Boolean principale = false; private Boolean principale = false;
/** Libellé personnalisé */ /** Libellé personnalisé */
@Column(name = "libelle", length = 100) @Column(name = "libelle", length = 100)
private String libelle; private String libelle;
/** Notes et commentaires */ /** Notes et commentaires */
@Column(name = "notes", length = 500) @Column(name = "notes", length = 500)
private String notes; private String notes;
// Relations // Relations
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id") @JoinColumn(name = "membre_id")
private Membre membre; private Membre membre;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "evenement_id") @JoinColumn(name = "evenement_id")
private Evenement evenement; private Evenement evenement;
/** Méthode métier pour obtenir l'adresse complète formatée */ /** Méthode métier pour obtenir l'adresse complète formatée */
public String getAdresseComplete() { public String getAdresseComplete() {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
if (adresse != null && !adresse.isEmpty()) { if (adresse != null && !adresse.isEmpty()) {
sb.append(adresse); sb.append(adresse);
} }
if (complementAdresse != null && !complementAdresse.isEmpty()) { if (complementAdresse != null && !complementAdresse.isEmpty()) {
if (sb.length() > 0) if (sb.length() > 0)
sb.append(", "); sb.append(", ");
sb.append(complementAdresse); sb.append(complementAdresse);
} }
if (codePostal != null && !codePostal.isEmpty()) { if (codePostal != null && !codePostal.isEmpty()) {
if (sb.length() > 0) if (sb.length() > 0)
sb.append(", "); sb.append(", ");
sb.append(codePostal); sb.append(codePostal);
} }
if (ville != null && !ville.isEmpty()) { if (ville != null && !ville.isEmpty()) {
if (sb.length() > 0) if (sb.length() > 0)
sb.append(" "); sb.append(" ");
sb.append(ville); sb.append(ville);
} }
if (region != null && !region.isEmpty()) { if (region != null && !region.isEmpty()) {
if (sb.length() > 0) if (sb.length() > 0)
sb.append(", "); sb.append(", ");
sb.append(region); sb.append(region);
} }
if (pays != null && !pays.isEmpty()) { if (pays != null && !pays.isEmpty()) {
if (sb.length() > 0) if (sb.length() > 0)
sb.append(", "); sb.append(", ");
sb.append(pays); sb.append(pays);
} }
return sb.toString(); return sb.toString();
} }
/** Méthode métier pour vérifier si l'adresse a des coordonnées GPS */ /** Méthode métier pour vérifier si l'adresse a des coordonnées GPS */
public boolean hasCoordinates() { public boolean hasCoordinates() {
return latitude != null && longitude != null; return latitude != null && longitude != null;
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
protected void onCreate() { protected void onCreate() {
super.onCreate(); // Appelle le onCreate de BaseEntity super.onCreate(); // Appelle le onCreate de BaseEntity
if (principale == null) { if (principale == null) {
principale = false; principale = false;
} }
} }
} }

View File

@@ -1,113 +1,113 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
/** /**
* Entité singleton pour la configuration des alertes système. * Entité singleton pour la configuration des alertes système.
* Une seule ligne en base de données. * Une seule ligne en base de données.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-15 * @since 2026-03-15
*/ */
@Entity @Entity
@Table(name = "alert_configuration") @Table(name = "alert_configuration")
@Getter @Getter
@Setter @Setter
public class AlertConfiguration extends BaseEntity { public class AlertConfiguration extends BaseEntity {
/** /**
* Alerte CPU activée * Alerte CPU activée
*/ */
@Column(name = "cpu_high_alert_enabled", nullable = false) @Column(name = "cpu_high_alert_enabled", nullable = false)
private Boolean cpuHighAlertEnabled = true; private Boolean cpuHighAlertEnabled = true;
/** /**
* Seuil CPU en pourcentage (0-100) * Seuil CPU en pourcentage (0-100)
*/ */
@Column(name = "cpu_threshold_percent", nullable = false) @Column(name = "cpu_threshold_percent", nullable = false)
private Integer cpuThresholdPercent = 80; private Integer cpuThresholdPercent = 80;
/** /**
* Durée en minutes avant déclenchement alerte CPU * Durée en minutes avant déclenchement alerte CPU
*/ */
@Column(name = "cpu_duration_minutes", nullable = false) @Column(name = "cpu_duration_minutes", nullable = false)
private Integer cpuDurationMinutes = 5; private Integer cpuDurationMinutes = 5;
/** /**
* Alerte mémoire faible activée * Alerte mémoire faible activée
*/ */
@Column(name = "memory_low_alert_enabled", nullable = false) @Column(name = "memory_low_alert_enabled", nullable = false)
private Boolean memoryLowAlertEnabled = true; private Boolean memoryLowAlertEnabled = true;
/** /**
* Seuil mémoire en pourcentage (0-100) * Seuil mémoire en pourcentage (0-100)
*/ */
@Column(name = "memory_threshold_percent", nullable = false) @Column(name = "memory_threshold_percent", nullable = false)
private Integer memoryThresholdPercent = 85; private Integer memoryThresholdPercent = 85;
/** /**
* Alerte erreur critique activée * Alerte erreur critique activée
*/ */
@Column(name = "critical_error_alert_enabled", nullable = false) @Column(name = "critical_error_alert_enabled", nullable = false)
private Boolean criticalErrorAlertEnabled = true; private Boolean criticalErrorAlertEnabled = true;
/** /**
* Alerte erreur activée * Alerte erreur activée
*/ */
@Column(name = "error_alert_enabled", nullable = false) @Column(name = "error_alert_enabled", nullable = false)
private Boolean errorAlertEnabled = true; private Boolean errorAlertEnabled = true;
/** /**
* Alerte échec de connexion activée * Alerte échec de connexion activée
*/ */
@Column(name = "connection_failure_alert_enabled", nullable = false) @Column(name = "connection_failure_alert_enabled", nullable = false)
private Boolean connectionFailureAlertEnabled = true; private Boolean connectionFailureAlertEnabled = true;
/** /**
* Seuil d'échecs de connexion * Seuil d'échecs de connexion
*/ */
@Column(name = "connection_failure_threshold", nullable = false) @Column(name = "connection_failure_threshold", nullable = false)
private Integer connectionFailureThreshold = 100; private Integer connectionFailureThreshold = 100;
/** /**
* Fenêtre temporelle en minutes pour les échecs de connexion * Fenêtre temporelle en minutes pour les échecs de connexion
*/ */
@Column(name = "connection_failure_window_minutes", nullable = false) @Column(name = "connection_failure_window_minutes", nullable = false)
private Integer connectionFailureWindowMinutes = 5; private Integer connectionFailureWindowMinutes = 5;
/** /**
* Notifications par email activées * Notifications par email activées
*/ */
@Column(name = "email_notifications_enabled", nullable = false) @Column(name = "email_notifications_enabled", nullable = false)
private Boolean emailNotificationsEnabled = true; private Boolean emailNotificationsEnabled = true;
/** /**
* Notifications push activées * Notifications push activées
*/ */
@Column(name = "push_notifications_enabled", nullable = false) @Column(name = "push_notifications_enabled", nullable = false)
private Boolean pushNotificationsEnabled = false; private Boolean pushNotificationsEnabled = false;
/** /**
* Notifications SMS activées * Notifications SMS activées
*/ */
@Column(name = "sms_notifications_enabled", nullable = false) @Column(name = "sms_notifications_enabled", nullable = false)
private Boolean smsNotificationsEnabled = false; private Boolean smsNotificationsEnabled = false;
/** /**
* Liste des emails destinataires des alertes (séparés par virgule) * Liste des emails destinataires des alertes (séparés par virgule)
*/ */
@Column(name = "alert_email_recipients", length = 1000) @Column(name = "alert_email_recipients", length = 1000)
private String alertEmailRecipients = "admin@unionflow.test"; private String alertEmailRecipients = "admin@unionflow.test";
/** /**
* S'assurer qu'il n'y a qu'une seule configuration * S'assurer qu'il n'y a qu'une seule configuration
*/ */
@PrePersist @PrePersist
@PreUpdate @PreUpdate
protected void ensureSingleton() { protected void ensureSingleton() {
// La logique singleton sera gérée par le repository // La logique singleton sera gérée par le repository
} }
} }

View File

@@ -1,124 +1,124 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
/** /**
* Entité représentant une alerte LCB-FT (Lutte Contre le Blanchiment et Financement du Terrorisme). * Entité représentant une alerte LCB-FT (Lutte Contre le Blanchiment et Financement du Terrorisme).
* Les alertes sont générées automatiquement lors de transactions dépassant les seuils configurés. * Les alertes sont générées automatiquement lors de transactions dépassant les seuils configurés.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-15 * @since 2026-03-15
*/ */
@Entity @Entity
@Table(name = "alertes_lcb_ft", indexes = { @Table(name = "alertes_lcb_ft", indexes = {
@Index(name = "idx_alerte_lcb_ft_organisation", columnList = "organisation_id"), @Index(name = "idx_alerte_lcb_ft_organisation", columnList = "organisation_id"),
@Index(name = "idx_alerte_lcb_ft_type", columnList = "type_alerte"), @Index(name = "idx_alerte_lcb_ft_type", columnList = "type_alerte"),
@Index(name = "idx_alerte_lcb_ft_date", columnList = "date_alerte"), @Index(name = "idx_alerte_lcb_ft_date", columnList = "date_alerte"),
@Index(name = "idx_alerte_lcb_ft_traitee", columnList = "traitee") @Index(name = "idx_alerte_lcb_ft_traitee", columnList = "traitee")
}) })
@Getter @Getter
@Setter @Setter
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
public class AlerteLcbFt extends BaseEntity { public class AlerteLcbFt extends BaseEntity {
/** /**
* Organisation concernée par l'alerte * Organisation concernée par l'alerte
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
/** /**
* Membre concerné par l'alerte * Membre concerné par l'alerte
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id") @JoinColumn(name = "membre_id")
private Membre membre; private Membre membre;
/** /**
* Type d'alerte : SEUIL_DEPASSE, JUSTIFICATION_MANQUANTE, etc. * Type d'alerte : SEUIL_DEPASSE, JUSTIFICATION_MANQUANTE, etc.
*/ */
@Column(name = "type_alerte", nullable = false, length = 50) @Column(name = "type_alerte", nullable = false, length = 50)
private String typeAlerte; private String typeAlerte;
/** /**
* Date et heure de génération de l'alerte * Date et heure de génération de l'alerte
*/ */
@Column(name = "date_alerte", nullable = false) @Column(name = "date_alerte", nullable = false)
private LocalDateTime dateAlerte; private LocalDateTime dateAlerte;
/** /**
* Description de l'alerte * Description de l'alerte
*/ */
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** /**
* Détails supplémentaires (JSON ou texte) * Détails supplémentaires (JSON ou texte)
*/ */
@Column(name = "details", columnDefinition = "TEXT") @Column(name = "details", columnDefinition = "TEXT")
private String details; private String details;
/** /**
* Montant de la transaction ayant généré l'alerte * Montant de la transaction ayant généré l'alerte
*/ */
@Column(name = "montant", precision = 15, scale = 2) @Column(name = "montant", precision = 15, scale = 2)
private BigDecimal montant; private BigDecimal montant;
/** /**
* Seuil qui a été dépassé * Seuil qui a été dépassé
*/ */
@Column(name = "seuil", precision = 15, scale = 2) @Column(name = "seuil", precision = 15, scale = 2)
private BigDecimal seuil; private BigDecimal seuil;
/** /**
* Type d'opération : DEPOT, RETRAIT, TRANSFERT, etc. * Type d'opération : DEPOT, RETRAIT, TRANSFERT, etc.
*/ */
@Column(name = "type_operation", length = 50) @Column(name = "type_operation", length = 50)
private String typeOperation; private String typeOperation;
/** /**
* Référence de la transaction concernée (UUID) * Référence de la transaction concernée (UUID)
*/ */
@Column(name = "transaction_ref", length = 100) @Column(name = "transaction_ref", length = 100)
private String transactionRef; private String transactionRef;
/** /**
* Niveau de gravité : INFO, WARNING, CRITICAL * Niveau de gravité : INFO, WARNING, CRITICAL
*/ */
@Column(name = "severite", nullable = false, length = 20) @Column(name = "severite", nullable = false, length = 20)
private String severite; private String severite;
/** /**
* Indique si l'alerte a été traitée * Indique si l'alerte a été traitée
*/ */
@Builder.Default @Builder.Default
@Column(name = "traitee", nullable = false) @Column(name = "traitee", nullable = false)
private Boolean traitee = false; private Boolean traitee = false;
/** /**
* Date de traitement de l'alerte * Date de traitement de l'alerte
*/ */
@Column(name = "date_traitement") @Column(name = "date_traitement")
private LocalDateTime dateTraitement; private LocalDateTime dateTraitement;
/** /**
* Utilisateur ayant traité l'alerte * Utilisateur ayant traité l'alerte
*/ */
@Column(name = "traite_par") @Column(name = "traite_par")
private UUID traitePar; private UUID traitePar;
/** /**
* Commentaire sur le traitement * Commentaire sur le traitement
*/ */
@Column(name = "commentaire_traitement", columnDefinition = "TEXT") @Column(name = "commentaire_traitement", columnDefinition = "TEXT")
private String commentaireTraitement; private String commentaireTraitement;
} }

View File

@@ -1,94 +1,94 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Action d'Approbateur * Entité Action d'Approbateur
* *
* Représente l'action (approve/reject) d'un approbateur sur une demande d'approbation. * Représente l'action (approve/reject) d'un approbateur sur une demande d'approbation.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-13 * @since 2026-03-13
*/ */
@Entity @Entity
@Table(name = "approver_actions", indexes = { @Table(name = "approver_actions", indexes = {
@Index(name = "idx_approver_action_approval", columnList = "approval_id"), @Index(name = "idx_approver_action_approval", columnList = "approval_id"),
@Index(name = "idx_approver_action_approver", columnList = "approver_id"), @Index(name = "idx_approver_action_approver", columnList = "approver_id"),
@Index(name = "idx_approver_action_decision", columnList = "decision"), @Index(name = "idx_approver_action_decision", columnList = "decision"),
@Index(name = "idx_approver_action_decided_at", columnList = "decided_at") @Index(name = "idx_approver_action_decided_at", columnList = "decided_at")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ApproverAction extends BaseEntity { public class ApproverAction extends BaseEntity {
/** Approbation parente */ /** Approbation parente */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "approval_id", nullable = false) @JoinColumn(name = "approval_id", nullable = false)
private TransactionApproval approval; private TransactionApproval approval;
/** ID de l'approbateur (membre) */ /** ID de l'approbateur (membre) */
@NotNull @NotNull
@Column(name = "approver_id", nullable = false) @Column(name = "approver_id", nullable = false)
private UUID approverId; private UUID approverId;
/** Nom complet de l'approbateur (cache) */ /** Nom complet de l'approbateur (cache) */
@NotBlank @NotBlank
@Column(name = "approver_name", nullable = false, length = 200) @Column(name = "approver_name", nullable = false, length = 200)
private String approverName; private String approverName;
/** Rôle de l'approbateur au moment de l'action */ /** Rôle de l'approbateur au moment de l'action */
@NotBlank @NotBlank
@Column(name = "approver_role", nullable = false, length = 50) @Column(name = "approver_role", nullable = false, length = 50)
private String approverRole; private String approverRole;
/** Décision (PENDING, APPROVED, REJECTED) */ /** Décision (PENDING, APPROVED, REJECTED) */
@NotBlank @NotBlank
@Pattern(regexp = "^(PENDING|APPROVED|REJECTED)$") @Pattern(regexp = "^(PENDING|APPROVED|REJECTED)$")
@Builder.Default @Builder.Default
@Column(name = "decision", nullable = false, length = 10) @Column(name = "decision", nullable = false, length = 10)
private String decision = "PENDING"; private String decision = "PENDING";
/** Commentaire optionnel */ /** Commentaire optionnel */
@Size(max = 1000) @Size(max = 1000)
@Column(name = "comment", length = 1000) @Column(name = "comment", length = 1000)
private String comment; private String comment;
/** Date de la décision */ /** Date de la décision */
@Column(name = "decided_at") @Column(name = "decided_at")
private LocalDateTime decidedAt; private LocalDateTime decidedAt;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (decision == null) { if (decision == null) {
decision = "PENDING"; decision = "PENDING";
} }
} }
/** Méthode métier pour approuver avec commentaire */ /** Méthode métier pour approuver avec commentaire */
public void approve(String comment) { public void approve(String comment) {
this.decision = "APPROVED"; this.decision = "APPROVED";
this.comment = comment; this.comment = comment;
this.decidedAt = LocalDateTime.now(); this.decidedAt = LocalDateTime.now();
} }
/** Méthode métier pour rejeter avec raison */ /** Méthode métier pour rejeter avec raison */
public void reject(String reason) { public void reject(String reason) {
this.decision = "REJECTED"; this.decision = "REJECTED";
this.comment = reason; this.comment = reason;
this.decidedAt = LocalDateTime.now(); this.decidedAt = LocalDateTime.now();
} }
} }

View File

@@ -1,99 +1,99 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.audit.PorteeAudit; import dev.lions.unionflow.server.api.enums.audit.PorteeAudit;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
/** /**
* Entité pour les logs d'audit * Entité pour les logs d'audit
* Enregistre toutes les actions importantes du système * Enregistre toutes les actions importantes du système
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2025-01-17 * @since 2025-01-17
*/ */
@Entity @Entity
@Table(name = "audit_logs", indexes = { @Table(name = "audit_logs", indexes = {
@Index(name = "idx_audit_date_heure", columnList = "date_heure"), @Index(name = "idx_audit_date_heure", columnList = "date_heure"),
@Index(name = "idx_audit_utilisateur", columnList = "utilisateur"), @Index(name = "idx_audit_utilisateur", columnList = "utilisateur"),
@Index(name = "idx_audit_module", columnList = "module"), @Index(name = "idx_audit_module", columnList = "module"),
@Index(name = "idx_audit_type_action", columnList = "type_action"), @Index(name = "idx_audit_type_action", columnList = "type_action"),
@Index(name = "idx_audit_severite", columnList = "severite") @Index(name = "idx_audit_severite", columnList = "severite")
}) })
@Getter @Getter
@Setter @Setter
public class AuditLog extends BaseEntity { public class AuditLog extends BaseEntity {
@Column(name = "type_action", nullable = false, length = 50) @Column(name = "type_action", nullable = false, length = 50)
private String typeAction; private String typeAction;
@Column(name = "severite", nullable = false, length = 20) @Column(name = "severite", nullable = false, length = 20)
private String severite; private String severite;
@Column(name = "utilisateur", length = 255) @Column(name = "utilisateur", length = 255)
private String utilisateur; private String utilisateur;
@Column(name = "role", length = 50) @Column(name = "role", length = 50)
private String role; private String role;
@Column(name = "module", length = 50) @Column(name = "module", length = 50)
private String module; private String module;
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
@Column(name = "details", columnDefinition = "TEXT") @Column(name = "details", columnDefinition = "TEXT")
private String details; private String details;
@Column(name = "ip_address", length = 45) @Column(name = "ip_address", length = 45)
private String ipAddress; private String ipAddress;
@Column(name = "user_agent", length = 500) @Column(name = "user_agent", length = 500)
private String userAgent; private String userAgent;
@Column(name = "session_id", length = 255) @Column(name = "session_id", length = 255)
private String sessionId; private String sessionId;
@Column(name = "date_heure", nullable = false) @Column(name = "date_heure", nullable = false)
private LocalDateTime dateHeure; private LocalDateTime dateHeure;
@Column(name = "donnees_avant", columnDefinition = "TEXT") @Column(name = "donnees_avant", columnDefinition = "TEXT")
private String donneesAvant; private String donneesAvant;
@Column(name = "donnees_apres", columnDefinition = "TEXT") @Column(name = "donnees_apres", columnDefinition = "TEXT")
private String donneesApres; private String donneesApres;
@Column(name = "entite_id", length = 255) @Column(name = "entite_id", length = 255)
private String entiteId; private String entiteId;
@Column(name = "entite_type", length = 100) @Column(name = "entite_type", length = 100)
private String entiteType; private String entiteType;
/** /**
* Organisation concernée par cet événement d'audit. * Organisation concernée par cet événement d'audit.
* NULL pour les événements de portée PLATEFORME. * NULL pour les événements de portée PLATEFORME.
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
/** /**
* Portée de visibilité : * Portée de visibilité :
* ORGANISATION = visible par le manager de l'organisation * ORGANISATION = visible par le manager de l'organisation
* PLATEFORME = visible uniquement par le Super Admin UnionFlow * PLATEFORME = visible uniquement par le Super Admin UnionFlow
*/ */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "portee", nullable = false, length = 15) @Column(name = "portee", nullable = false, length = 15)
private PorteeAudit portee = PorteeAudit.PLATEFORME; private PorteeAudit portee = PorteeAudit.PLATEFORME;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (dateHeure == null) { if (dateHeure == null) {
dateHeure = LocalDateTime.now(); dateHeure = LocalDateTime.now();
} }
} }
} }

View File

@@ -0,0 +1,104 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* Entrée d'audit trail enrichi (SYSCOHADA + AUDSCGIE OHADA).
*
* <p>Trace les opérations financières, le lifecycle membres, les changements de configuration,
* avec le contexte multi-org (rôle actif + organisation active) + vérifications de séparation des
* pouvoirs (SoD).
*
* <p>Cette entité ne dérive PAS de {@link BaseEntity} car elle représente un enregistrement
* immuable d'historique : ses propres champs d'audit ({@code operationAt}, {@code userId}) sont
* la donnée à tracer.
*
* @since 2026-04-25 — exigences SYSCOHADA + Instruction BCEAO 003-03-2025 (audit KYC)
*/
@Entity
@Table(name = "audit_trail_operations")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuditTrailOperation {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", updatable = false, nullable = false)
private UUID id;
// Acteur
@NotNull
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "user_email", length = 255)
private String userEmail;
@Column(name = "role_actif", length = 50)
private String roleActif;
@Column(name = "organisation_active_id")
private UUID organisationActiveId;
// Action
@NotBlank
@Column(name = "action_type", nullable = false, length = 50)
private String actionType;
@NotBlank
@Column(name = "entity_type", nullable = false, length = 100)
private String entityType;
@Column(name = "entity_id")
private UUID entityId;
@Column(name = "description", length = 500)
private String description;
// Contexte
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(name = "request_id")
private UUID requestId;
// Données (JSONB)
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload_avant", columnDefinition = "jsonb")
private String payloadAvant;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload_apres", columnDefinition = "jsonb")
private String payloadApres;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "metadata", columnDefinition = "jsonb")
private String metadata;
// SoD
@Column(name = "sod_check_passed")
private Boolean sodCheckPassed;
@Column(name = "sod_violations", length = 500)
private String sodViolations;
@NotNull
@Column(name = "operation_at", nullable = false)
@Builder.Default
private LocalDateTime operationAt = LocalDateTime.now();
}

View File

@@ -1,95 +1,95 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.ayantdroit.LienParente; import dev.lions.unionflow.server.api.enums.ayantdroit.LienParente;
import dev.lions.unionflow.server.api.enums.ayantdroit.StatutAyantDroit; import dev.lions.unionflow.server.api.enums.ayantdroit.StatutAyantDroit;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.math.BigDecimal; import java.math.BigDecimal;
import lombok.*; import lombok.*;
/** /**
* Ayant droit d'un membre dans une mutuelle de santé. * Ayant droit d'un membre dans une mutuelle de santé.
* *
* <p> * <p>
* Permet la gestion des bénéficiaires (conjoint, enfants, parents) pour * Permet la gestion des bénéficiaires (conjoint, enfants, parents) pour
* les conventions avec les centres de santé partenaires et les plafonds * les conventions avec les centres de santé partenaires et les plafonds
* annuels. * annuels.
* *
* <p> * <p>
* Table : {@code ayants_droit} * Table : {@code ayants_droit}
*/ */
@Entity @Entity
@Table(name = "ayants_droit", indexes = { @Table(name = "ayants_droit", indexes = {
@Index(name = "idx_ad_membre_org", columnList = "membre_organisation_id"), @Index(name = "idx_ad_membre_org", columnList = "membre_organisation_id"),
@Index(name = "idx_ad_couverture", columnList = "date_debut_couverture, date_fin_couverture") @Index(name = "idx_ad_couverture", columnList = "date_debut_couverture, date_fin_couverture")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class AyantDroit extends BaseEntity { public class AyantDroit extends BaseEntity {
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_organisation_id", nullable = false) @JoinColumn(name = "membre_organisation_id", nullable = false)
private MembreOrganisation membreOrganisation; private MembreOrganisation membreOrganisation;
@NotBlank @NotBlank
@Column(name = "prenom", nullable = false, length = 100) @Column(name = "prenom", nullable = false, length = 100)
private String prenom; private String prenom;
@NotBlank @NotBlank
@Column(name = "nom", nullable = false, length = 100) @Column(name = "nom", nullable = false, length = 100)
private String nom; private String nom;
@Column(name = "date_naissance") @Column(name = "date_naissance")
private LocalDate dateNaissance; private LocalDate dateNaissance;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@NotNull @NotNull
@Column(name = "lien_parente", nullable = false, length = 20) @Column(name = "lien_parente", nullable = false, length = 20)
private LienParente lienParente; private LienParente lienParente;
/** Numéro attribué pour les conventions santé avec les centres partenaires */ /** Numéro attribué pour les conventions santé avec les centres partenaires */
@Column(name = "numero_beneficiaire", length = 50) @Column(name = "numero_beneficiaire", length = 50)
private String numeroBeneficiaire; private String numeroBeneficiaire;
@Column(name = "date_debut_couverture") @Column(name = "date_debut_couverture")
private LocalDate dateDebutCouverture; private LocalDate dateDebutCouverture;
/** NULL = couverture ouverte */ /** NULL = couverture ouverte */
@Column(name = "date_fin_couverture") @Column(name = "date_fin_couverture")
private LocalDate dateFinCouverture; private LocalDate dateFinCouverture;
@Column(name = "sexe", length = 20) @Column(name = "sexe", length = 20)
private String sexe; private String sexe;
@Column(name = "piece_identite", length = 100) @Column(name = "piece_identite", length = 100)
private String pieceIdentite; private String pieceIdentite;
@Column(name = "pourcentage_couverture", precision = 5, scale = 2) @Column(name = "pourcentage_couverture", precision = 5, scale = 2)
private BigDecimal pourcentageCouvertureSante; private BigDecimal pourcentageCouvertureSante;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutAyantDroit statut = StatutAyantDroit.EN_ATTENTE; private StatutAyantDroit statut = StatutAyantDroit.EN_ATTENTE;
// ── Méthodes métier ──────────────────────────────────────────────────────── // ── Méthodes métier ────────────────────────────────────────────────────────
public boolean isCouvertAujourdhui() { public boolean isCouvertAujourdhui() {
LocalDate today = LocalDate.now(); LocalDate today = LocalDate.now();
if (dateDebutCouverture != null && today.isBefore(dateDebutCouverture)) if (dateDebutCouverture != null && today.isBefore(dateDebutCouverture))
return false; return false;
if (dateFinCouverture != null && today.isAfter(dateFinCouverture)) if (dateFinCouverture != null && today.isAfter(dateFinCouverture))
return false; return false;
return Boolean.TRUE.equals(getActif()); return Boolean.TRUE.equals(getActif());
} }
public String getNomComplet() { public String getNomComplet() {
return prenom + " " + nom; return prenom + " " + nom;
} }
} }

View File

@@ -1,101 +1,101 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.entity.listener.AuditEntityListener; import dev.lions.unionflow.server.entity.listener.AuditEntityListener;
import io.quarkus.hibernate.orm.panache.PanacheEntityBase; import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners; import jakarta.persistence.EntityListeners;
import jakarta.persistence.GeneratedValue; import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType; import jakarta.persistence.GenerationType;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.MappedSuperclass; import jakarta.persistence.MappedSuperclass;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import jakarta.persistence.Version; import jakarta.persistence.Version;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
/** /**
* Classe de base pour toutes les entités UnionFlow. * Classe de base pour toutes les entités UnionFlow.
* *
* <p> * <p>
* Étend PanacheEntityBase pour bénéficier du pattern Active Record et résoudre * Étend PanacheEntityBase pour bénéficier du pattern Active Record et résoudre
* les warnings Hibernate. * les warnings Hibernate.
* Fournit les champs communs d'audit et le versioning optimistic. * Fournit les champs communs d'audit et le versioning optimistic.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 4.0 * @version 4.0
*/ */
@MappedSuperclass @MappedSuperclass
@EntityListeners(AuditEntityListener.class) @EntityListeners(AuditEntityListener.class)
@Data @Data
@EqualsAndHashCode(callSuper = false) @EqualsAndHashCode(callSuper = false)
public abstract class BaseEntity extends PanacheEntityBase { public abstract class BaseEntity extends PanacheEntityBase {
/** Identifiant unique auto-généré. */ /** Identifiant unique auto-généré. */
@Id @Id
@GeneratedValue(strategy = GenerationType.UUID) @GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id", updatable = false, nullable = false) @Column(name = "id", updatable = false, nullable = false)
private UUID id; private UUID id;
/** /**
* Date de création. * Date de création.
*/ */
@Column(name = "date_creation", nullable = false, updatable = false) @Column(name = "date_creation", nullable = false, updatable = false)
private LocalDateTime dateCreation; private LocalDateTime dateCreation;
/** /**
* Date de dernière modification. * Date de dernière modification.
*/ */
@Column(name = "date_modification") @Column(name = "date_modification")
private LocalDateTime dateModification; private LocalDateTime dateModification;
/** /**
* Email de l'utilisateur ayant créé l'entité. * Email de l'utilisateur ayant créé l'entité.
*/ */
@Column(name = "cree_par", length = 255) @Column(name = "cree_par", length = 255)
private String creePar; private String creePar;
/** /**
* Email du dernier utilisateur ayant modifié l'entité. * Email du dernier utilisateur ayant modifié l'entité.
*/ */
@Column(name = "modifie_par", length = 255) @Column(name = "modifie_par", length = 255)
private String modifiePar; private String modifiePar;
/** Version pour l'optimistic locking JPA. */ /** Version pour l'optimistic locking JPA. */
@Version @Version
@Column(name = "version") @Column(name = "version")
private Long version; private Long version;
/** /**
* État actif/inactif pour le soft-delete. * État actif/inactif pour le soft-delete.
*/ */
@Column(name = "actif", nullable = false) @Column(name = "actif", nullable = false)
private Boolean actif; private Boolean actif;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
if (this.dateCreation == null) { if (this.dateCreation == null) {
this.dateCreation = LocalDateTime.now(); this.dateCreation = LocalDateTime.now();
} }
if (this.actif == null) { if (this.actif == null) {
this.actif = true; this.actif = true;
} }
} }
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
this.dateModification = LocalDateTime.now(); this.dateModification = LocalDateTime.now();
} }
/** /**
* Marque l'entité comme modifiée par un utilisateur donné. * Marque l'entité comme modifiée par un utilisateur donné.
* *
* @param utilisateur email de l'utilisateur * @param utilisateur email de l'utilisateur
*/ */
public void marquerCommeModifie(String utilisateur) { public void marquerCommeModifie(String utilisateur) {
this.dateModification = LocalDateTime.now(); this.dateModification = LocalDateTime.now();
this.modifiePar = utilisateur; this.modifiePar = utilisateur;
} }
} }

View File

@@ -0,0 +1,168 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Bénéficiaire effectif (UBO — Ultimate Beneficial Owner) lié à un dossier KYC.
*
* <p>Implémente l'obligation introduite par l'<strong>Instruction BCEAO 003-03-2025 du 18 mars
* 2025</strong> : identification, vérification et connaissance du client par les institutions
* financières — vérification systématique des bénéficiaires effectifs obligatoire (approche par
* les risques).
*
* <p>Un bénéficiaire effectif est, selon la directive UEMOA et le GAFI/FATF, toute personne
* physique qui :
*
* <ul>
* <li>détient au moins <strong>25 %</strong> du capital ou des droits de vote d'une personne
* morale ;
* <li>OU exerce un contrôle effectif (de fait ou de droit) sur la gestion de l'entité ;
* <li>OU est bénéficiaire ultime d'une opération suspecte structurée.
* </ul>
*
* <p>Ces enregistrements doivent être conservés <strong>10 ans</strong> après la clôture de la
* relation d'affaires (directive 02/2015/CM/UEMOA).
*
* @since 2026-04-25 — Instruction BCEAO 003-03-2025 (KYC + UBO)
*/
@Entity
@Table(
name = "beneficiaires_effectifs",
indexes = {
@Index(name = "idx_ubo_kyc_dossier", columnList = "kyc_dossier_id"),
@Index(name = "idx_ubo_organisation_cible", columnList = "organisation_cible_id"),
@Index(name = "idx_ubo_pays", columnList = "pays_residence")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class BeneficiaireEffectif extends BaseEntity {
/** Dossier KYC auquel ce bénéficiaire effectif est rattaché. */
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "kyc_dossier_id", nullable = false)
private KycDossier kycDossier;
/**
* Organisation cible dont cette personne est bénéficiaire effectif (utile en cas de KYC client
* personne morale — la chaîne de contrôle UBO peut traverser plusieurs entités).
*/
@Column(name = "organisation_cible_id")
private UUID organisationCibleId;
/** Lien vers le membre UnionFlow correspondant si applicable (UBO interne au système). */
@Column(name = "membre_id")
private UUID membreId;
// Identité
@NotBlank
@Column(name = "nom", nullable = false, length = 100)
private String nom;
@NotBlank
@Column(name = "prenoms", nullable = false, length = 200)
private String prenoms;
@NotNull
@Column(name = "date_naissance", nullable = false)
private LocalDate dateNaissance;
@Column(name = "lieu_naissance", length = 200)
private String lieuNaissance;
@NotBlank
@Column(name = "nationalite", nullable = false, length = 3)
private String nationalite; // ISO 3166-1 alpha-3
@Column(name = "pays_residence", length = 3)
private String paysResidence;
// Pièce d'identité
@Enumerated(EnumType.STRING)
@Column(name = "type_piece_identite", length = 30)
private TypePieceIdentite typePieceIdentite;
@Column(name = "numero_piece_identite", length = 50)
private String numeroPieceIdentite;
@Column(name = "date_expiration_piece")
private LocalDate dateExpirationPiece;
// Contrôle
/**
* Pourcentage de détention en capital (0-100). Si {@code >= 25} → UBO direct selon GAFI.
* Peut être null si le contrôle est exercé autrement (mandat, accord d'actionnaires).
*/
@DecimalMin("0.00")
@DecimalMax("100.00")
@Column(name = "pourcentage_capital", precision = 5, scale = 2)
private BigDecimal pourcentageCapital;
/** Pourcentage des droits de vote (0-100). */
@DecimalMin("0.00")
@DecimalMax("100.00")
@Column(name = "pourcentage_droits_vote", precision = 5, scale = 2)
private BigDecimal pourcentageDroitsVote;
/**
* Nature du contrôle exercé : DETENTION_CAPITAL, DROITS_VOTE, CONTROLE_DE_FAIT,
* BENEFICIAIRE_ULTIME, MANDAT_REPRESENTATION.
*/
@NotBlank
@Column(name = "nature_controle", nullable = false, length = 50)
private String natureControle;
// Politique d'exposition (PEP)
@Column(name = "est_pep", nullable = false)
@Builder.Default
private boolean estPep = false;
@Column(name = "pep_categorie", length = 100)
private String pepCategorie;
@Column(name = "pep_pays", length = 3)
private String pepPays;
@Column(name = "pep_fonction", length = 200)
private String pepFonction;
// Sanctions / vigilance
@Column(name = "presence_listes_sanctions", nullable = false)
@Builder.Default
private boolean presenceListesSanctions = false;
@Column(name = "details_listes_sanctions", length = 1000)
private String detailsListesSanctions;
// Vérification
@Column(name = "verifie_par_id")
private UUID verifieParId;
@Column(name = "date_verification")
private java.time.LocalDateTime dateVerification;
@Column(name = "source_verification", length = 200)
private String sourceVerification;
@Column(name = "notes", length = 2000)
private String notes;
}

View File

@@ -1,218 +1,218 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Budget * Entité Budget
* *
* Représente un budget prévisionnel (mensuel/trimestriel/annuel) avec suivi de réalisation. * Représente un budget prévisionnel (mensuel/trimestriel/annuel) avec suivi de réalisation.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-13 * @since 2026-03-13
*/ */
@Entity @Entity
@Table(name = "budgets", indexes = { @Table(name = "budgets", indexes = {
@Index(name = "idx_budget_organisation", columnList = "organisation_id"), @Index(name = "idx_budget_organisation", columnList = "organisation_id"),
@Index(name = "idx_budget_status", columnList = "status"), @Index(name = "idx_budget_status", columnList = "status"),
@Index(name = "idx_budget_period", columnList = "period"), @Index(name = "idx_budget_period", columnList = "period"),
@Index(name = "idx_budget_year_month", columnList = "year, month"), @Index(name = "idx_budget_year_month", columnList = "year, month"),
@Index(name = "idx_budget_created_by", columnList = "created_by_id") @Index(name = "idx_budget_created_by", columnList = "created_by_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Budget extends BaseEntity { public class Budget extends BaseEntity {
/** Nom du budget */ /** Nom du budget */
@NotBlank @NotBlank
@Size(max = 200) @Size(max = 200)
@Column(name = "name", nullable = false, length = 200) @Column(name = "name", nullable = false, length = 200)
private String name; private String name;
/** Description optionnelle */ /** Description optionnelle */
@Size(max = 1000) @Size(max = 1000)
@Column(name = "description", length = 1000) @Column(name = "description", length = 1000)
private String description; private String description;
/** Organisation concernée */ /** Organisation concernée */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
/** Période (MONTHLY, QUARTERLY, SEMIANNUAL, ANNUAL) */ /** Période (MONTHLY, QUARTERLY, SEMIANNUAL, ANNUAL) */
@NotBlank @NotBlank
@Pattern(regexp = "^(MONTHLY|QUARTERLY|SEMIANNUAL|ANNUAL)$") @Pattern(regexp = "^(MONTHLY|QUARTERLY|SEMIANNUAL|ANNUAL)$")
@Column(name = "period", nullable = false, length = 20) @Column(name = "period", nullable = false, length = 20)
private String period; private String period;
/** Année du budget */ /** Année du budget */
@NotNull @NotNull
@Min(value = 2020, message = "L'année doit être >= 2020") @Min(value = 2020, message = "L'année doit être >= 2020")
@Max(value = 2100, message = "L'année doit être <= 2100") @Max(value = 2100, message = "L'année doit être <= 2100")
@Column(name = "year", nullable = false) @Column(name = "year", nullable = false)
private Integer year; private Integer year;
/** Mois (1-12) pour budget mensuel, null sinon */ /** Mois (1-12) pour budget mensuel, null sinon */
@Min(value = 1) @Min(value = 1)
@Max(value = 12) @Max(value = 12)
@Column(name = "month") @Column(name = "month")
private Integer month; private Integer month;
/** Statut (DRAFT, ACTIVE, CLOSED, CANCELLED) */ /** Statut (DRAFT, ACTIVE, CLOSED, CANCELLED) */
@NotBlank @NotBlank
@Pattern(regexp = "^(DRAFT|ACTIVE|CLOSED|CANCELLED)$") @Pattern(regexp = "^(DRAFT|ACTIVE|CLOSED|CANCELLED)$")
@Builder.Default @Builder.Default
@Column(name = "status", nullable = false, length = 20) @Column(name = "status", nullable = false, length = 20)
private String status = "DRAFT"; private String status = "DRAFT";
/** Lignes budgétaires */ /** Lignes budgétaires */
@OneToMany(mappedBy = "budget", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OneToMany(mappedBy = "budget", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<BudgetLine> lines = new ArrayList<>(); private List<BudgetLine> lines = new ArrayList<>();
/** Total prévu (somme des montants prévus des lignes) */ /** Total prévu (somme des montants prévus des lignes) */
@NotNull @NotNull
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 14, fraction = 2) @Digits(integer = 14, fraction = 2)
@Builder.Default @Builder.Default
@Column(name = "total_planned", nullable = false, precision = 16, scale = 2) @Column(name = "total_planned", nullable = false, precision = 16, scale = 2)
private BigDecimal totalPlanned = BigDecimal.ZERO; private BigDecimal totalPlanned = BigDecimal.ZERO;
/** Total réalisé (somme des montants réalisés des lignes) */ /** Total réalisé (somme des montants réalisés des lignes) */
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 14, fraction = 2) @Digits(integer = 14, fraction = 2)
@Builder.Default @Builder.Default
@Column(name = "total_realized", nullable = false, precision = 16, scale = 2) @Column(name = "total_realized", nullable = false, precision = 16, scale = 2)
private BigDecimal totalRealized = BigDecimal.ZERO; private BigDecimal totalRealized = BigDecimal.ZERO;
/** Code devise ISO 3 lettres */ /** Code devise ISO 3 lettres */
@NotBlank @NotBlank
@Pattern(regexp = "^[A-Z]{3}$") @Pattern(regexp = "^[A-Z]{3}$")
@Builder.Default @Builder.Default
@Column(name = "currency", nullable = false, length = 3) @Column(name = "currency", nullable = false, length = 3)
private String currency = "XOF"; private String currency = "XOF";
/** ID du créateur du budget */ /** ID du créateur du budget */
@NotNull @NotNull
@Column(name = "created_by_id", nullable = false) @Column(name = "created_by_id", nullable = false)
private UUID createdById; private UUID createdById;
/** Date de création */ /** Date de création */
@NotNull @NotNull
@Column(name = "created_at_budget", nullable = false) @Column(name = "created_at_budget", nullable = false)
private LocalDateTime createdAtBudget; private LocalDateTime createdAtBudget;
/** Date d'approbation */ /** Date d'approbation */
@Column(name = "approved_at") @Column(name = "approved_at")
private LocalDateTime approvedAt; private LocalDateTime approvedAt;
/** ID de l'approbateur */ /** ID de l'approbateur */
@Column(name = "approved_by_id") @Column(name = "approved_by_id")
private UUID approvedById; private UUID approvedById;
/** Date de début de la période budgétaire */ /** Date de début de la période budgétaire */
@NotNull @NotNull
@Column(name = "start_date", nullable = false) @Column(name = "start_date", nullable = false)
private LocalDate startDate; private LocalDate startDate;
/** Date de fin de la période budgétaire */ /** Date de fin de la période budgétaire */
@NotNull @NotNull
@Column(name = "end_date", nullable = false) @Column(name = "end_date", nullable = false)
private LocalDate endDate; private LocalDate endDate;
/** Métadonnées additionnelles (JSON) */ /** Métadonnées additionnelles (JSON) */
@Column(name = "metadata", columnDefinition = "TEXT") @Column(name = "metadata", columnDefinition = "TEXT")
private String metadata; private String metadata;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (createdAtBudget == null) { if (createdAtBudget == null) {
createdAtBudget = LocalDateTime.now(); createdAtBudget = LocalDateTime.now();
} }
if (currency == null) { if (currency == null) {
currency = "XOF"; currency = "XOF";
} }
if (status == null) { if (status == null) {
status = "DRAFT"; status = "DRAFT";
} }
if (totalPlanned == null) { if (totalPlanned == null) {
totalPlanned = BigDecimal.ZERO; totalPlanned = BigDecimal.ZERO;
} }
if (totalRealized == null) { if (totalRealized == null) {
totalRealized = BigDecimal.ZERO; totalRealized = BigDecimal.ZERO;
} }
} }
/** Méthode métier pour ajouter une ligne budgétaire */ /** Méthode métier pour ajouter une ligne budgétaire */
public void addLine(BudgetLine line) { public void addLine(BudgetLine line) {
lines.add(line); lines.add(line);
line.setBudget(this); line.setBudget(this);
recalculateTotals(); recalculateTotals();
} }
/** Méthode métier pour supprimer une ligne budgétaire */ /** Méthode métier pour supprimer une ligne budgétaire */
public void removeLine(BudgetLine line) { public void removeLine(BudgetLine line) {
lines.remove(line); lines.remove(line);
line.setBudget(null); line.setBudget(null);
recalculateTotals(); recalculateTotals();
} }
/** Méthode métier pour recalculer les totaux */ /** Méthode métier pour recalculer les totaux */
public void recalculateTotals() { public void recalculateTotals() {
this.totalPlanned = lines.stream() this.totalPlanned = lines.stream()
.map(BudgetLine::getAmountPlanned) .map(BudgetLine::getAmountPlanned)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
this.totalRealized = lines.stream() this.totalRealized = lines.stream()
.map(BudgetLine::getAmountRealized) .map(BudgetLine::getAmountRealized)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
} }
/** Méthode métier pour calculer le taux de réalisation (%) */ /** Méthode métier pour calculer le taux de réalisation (%) */
public double getRealizationRate() { public double getRealizationRate() {
if (totalPlanned.compareTo(BigDecimal.ZERO) == 0) { if (totalPlanned.compareTo(BigDecimal.ZERO) == 0) {
return 0.0; return 0.0;
} }
return totalRealized.divide(totalPlanned, 4, java.math.RoundingMode.HALF_UP) return totalRealized.divide(totalPlanned, 4, java.math.RoundingMode.HALF_UP)
.multiply(new BigDecimal("100")) .multiply(new BigDecimal("100"))
.doubleValue(); .doubleValue();
} }
/** Méthode métier pour calculer l'écart (réalisé - prévu) */ /** Méthode métier pour calculer l'écart (réalisé - prévu) */
public BigDecimal getVariance() { public BigDecimal getVariance() {
return totalRealized.subtract(totalPlanned); return totalRealized.subtract(totalPlanned);
} }
/** Méthode métier pour vérifier si le budget est dépassé */ /** Méthode métier pour vérifier si le budget est dépassé */
public boolean isOverBudget() { public boolean isOverBudget() {
return totalRealized.compareTo(totalPlanned) > 0; return totalRealized.compareTo(totalPlanned) > 0;
} }
/** Méthode métier pour vérifier si le budget est actif */ /** Méthode métier pour vérifier si le budget est actif */
public boolean isActive() { public boolean isActive() {
return "ACTIVE".equals(status); return "ACTIVE".equals(status);
} }
/** Méthode métier pour vérifier si la période est en cours */ /** Méthode métier pour vérifier si la période est en cours */
public boolean isCurrentPeriod() { public boolean isCurrentPeriod() {
LocalDate now = LocalDate.now(); LocalDate now = LocalDate.now();
return !now.isBefore(startDate) && !now.isAfter(endDate); return !now.isBefore(startDate) && !now.isAfter(endDate);
} }
} }

View File

@@ -1,102 +1,102 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Ligne Budgétaire * Entité Ligne Budgétaire
* *
* Représente une ligne dans un budget (catégorie de dépense/recette). * Représente une ligne dans un budget (catégorie de dépense/recette).
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-13 * @since 2026-03-13
*/ */
@Entity @Entity
@Table(name = "budget_lines", indexes = { @Table(name = "budget_lines", indexes = {
@Index(name = "idx_budget_line_budget", columnList = "budget_id"), @Index(name = "idx_budget_line_budget", columnList = "budget_id"),
@Index(name = "idx_budget_line_category", columnList = "category") @Index(name = "idx_budget_line_category", columnList = "category")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class BudgetLine extends BaseEntity { public class BudgetLine extends BaseEntity {
/** Budget parent */ /** Budget parent */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "budget_id", nullable = false) @JoinColumn(name = "budget_id", nullable = false)
private Budget budget; private Budget budget;
/** Catégorie (CONTRIBUTIONS, SAVINGS, SOLIDARITY, EVENTS, OPERATIONAL, INVESTMENTS, OTHER) */ /** Catégorie (CONTRIBUTIONS, SAVINGS, SOLIDARITY, EVENTS, OPERATIONAL, INVESTMENTS, OTHER) */
@NotBlank @NotBlank
@Pattern(regexp = "^(CONTRIBUTIONS|SAVINGS|SOLIDARITY|EVENTS|OPERATIONAL|INVESTMENTS|OTHER)$") @Pattern(regexp = "^(CONTRIBUTIONS|SAVINGS|SOLIDARITY|EVENTS|OPERATIONAL|INVESTMENTS|OTHER)$")
@Column(name = "category", nullable = false, length = 20) @Column(name = "category", nullable = false, length = 20)
private String category; private String category;
/** Nom de la ligne */ /** Nom de la ligne */
@NotBlank @NotBlank
@Size(max = 200) @Size(max = 200)
@Column(name = "name", nullable = false, length = 200) @Column(name = "name", nullable = false, length = 200)
private String name; private String name;
/** Description optionnelle */ /** Description optionnelle */
@Size(max = 500) @Size(max = 500)
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** Montant prévu */ /** Montant prévu */
@NotNull @NotNull
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 14, fraction = 2) @Digits(integer = 14, fraction = 2)
@Column(name = "amount_planned", nullable = false, precision = 16, scale = 2) @Column(name = "amount_planned", nullable = false, precision = 16, scale = 2)
private BigDecimal amountPlanned; private BigDecimal amountPlanned;
/** Montant réalisé */ /** Montant réalisé */
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 14, fraction = 2) @Digits(integer = 14, fraction = 2)
@Builder.Default @Builder.Default
@Column(name = "amount_realized", nullable = false, precision = 16, scale = 2) @Column(name = "amount_realized", nullable = false, precision = 16, scale = 2)
private BigDecimal amountRealized = BigDecimal.ZERO; private BigDecimal amountRealized = BigDecimal.ZERO;
/** Notes additionnelles */ /** Notes additionnelles */
@Size(max = 1000) @Size(max = 1000)
@Column(name = "notes", length = 1000) @Column(name = "notes", length = 1000)
private String notes; private String notes;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (amountRealized == null) { if (amountRealized == null) {
amountRealized = BigDecimal.ZERO; amountRealized = BigDecimal.ZERO;
} }
} }
/** Méthode métier pour calculer le taux de réalisation (%) */ /** Méthode métier pour calculer le taux de réalisation (%) */
public double getRealizationRate() { public double getRealizationRate() {
if (amountPlanned.compareTo(BigDecimal.ZERO) == 0) { if (amountPlanned.compareTo(BigDecimal.ZERO) == 0) {
return 0.0; return 0.0;
} }
return amountRealized.divide(amountPlanned, 4, java.math.RoundingMode.HALF_UP) return amountRealized.divide(amountPlanned, 4, java.math.RoundingMode.HALF_UP)
.multiply(new BigDecimal("100")) .multiply(new BigDecimal("100"))
.doubleValue(); .doubleValue();
} }
/** Méthode métier pour calculer l'écart */ /** Méthode métier pour calculer l'écart */
public BigDecimal getVariance() { public BigDecimal getVariance() {
return amountRealized.subtract(amountPlanned); return amountRealized.subtract(amountPlanned);
} }
/** Méthode métier pour vérifier si la ligne est dépassée */ /** Méthode métier pour vérifier si la ligne est dépassée */
public boolean isOverBudget() { public boolean isOverBudget() {
return amountRealized.compareTo(amountPlanned) > 0; return amountRealized.compareTo(amountPlanned) > 0;
} }
} }

View File

@@ -1,122 +1,127 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.comptabilite.TypeCompteComptable; import dev.lions.unionflow.server.api.enums.comptabilite.TypeCompteComptable;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité CompteComptable pour le plan comptable * Entité CompteComptable pour le plan comptable
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "comptes_comptables", name = "comptes_comptables",
indexes = { indexes = {
@Index(name = "idx_compte_numero", columnList = "numero_compte", unique = true), @Index(name = "idx_compte_numero", columnList = "numero_compte", unique = true),
@Index(name = "idx_compte_type", columnList = "type_compte"), @Index(name = "idx_compte_type", columnList = "type_compte"),
@Index(name = "idx_compte_classe", columnList = "classe_comptable") @Index(name = "idx_compte_classe", columnList = "classe_comptable")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class CompteComptable extends BaseEntity { public class CompteComptable extends BaseEntity {
/** Numéro de compte unique (ex: 411000, 512000) */ /** Numéro de compte unique (ex: 411000, 512000) */
@NotBlank @NotBlank
@Column(name = "numero_compte", unique = true, nullable = false, length = 10) @Column(name = "numero_compte", unique = true, nullable = false, length = 10)
private String numeroCompte; private String numeroCompte;
/** Libellé du compte */ /** Libellé du compte */
@NotBlank @NotBlank
@Column(name = "libelle", nullable = false, length = 200) @Column(name = "libelle", nullable = false, length = 200)
private String libelle; private String libelle;
/** Type de compte */ /** Type de compte */
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_compte", nullable = false, length = 30) @Column(name = "type_compte", nullable = false, length = 30)
private TypeCompteComptable typeCompte; private TypeCompteComptable typeCompte;
/** Classe comptable (1-7) */ /** Classe comptable (1-7) */
@NotNull @NotNull
@Min(value = 1, message = "La classe comptable doit être entre 1 et 7") @Min(value = 1, message = "La classe comptable doit être entre 1 et 9")
@Max(value = 7, message = "La classe comptable doit être entre 1 et 7") @Max(value = 9, message = "La classe comptable doit être entre 1 et 9")
@Column(name = "classe_comptable", nullable = false) @Column(name = "classe_comptable", nullable = false)
private Integer classeComptable; private Integer classeComptable;
/** Solde initial */ /** Solde initial */
@Builder.Default @Builder.Default
@DecimalMin(value = "0.0", message = "Le solde initial doit être positif ou nul") @DecimalMin(value = "0.0", message = "Le solde initial doit être positif ou nul")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "solde_initial", precision = 14, scale = 2) @Column(name = "solde_initial", precision = 14, scale = 2)
private BigDecimal soldeInitial = BigDecimal.ZERO; private BigDecimal soldeInitial = BigDecimal.ZERO;
/** Solde actuel (calculé) */ /** Solde actuel (calculé) */
@Builder.Default @Builder.Default
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "solde_actuel", precision = 14, scale = 2) @Column(name = "solde_actuel", precision = 14, scale = 2)
private BigDecimal soldeActuel = BigDecimal.ZERO; private BigDecimal soldeActuel = BigDecimal.ZERO;
/** Compte collectif (regroupe plusieurs sous-comptes) */ /** Compte collectif (regroupe plusieurs sous-comptes) */
@Builder.Default @Builder.Default
@Column(name = "compte_collectif", nullable = false) @Column(name = "compte_collectif", nullable = false)
private Boolean compteCollectif = false; private Boolean compteCollectif = false;
/** Compte analytique */ /** Compte analytique */
@Builder.Default @Builder.Default
@Column(name = "compte_analytique", nullable = false) @Column(name = "compte_analytique", nullable = false)
private Boolean compteAnalytique = false; private Boolean compteAnalytique = false;
/** Description du compte */ /** Description du compte */
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** Lignes d'écriture associées */ /** Organisation propriétaire (null = compte standard global) */
@JsonIgnore @ManyToOne(fetch = FetchType.LAZY)
@OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @JoinColumn(name = "organisation_id")
@Builder.Default private Organisation organisation;
private List<LigneEcriture> lignesEcriture = new ArrayList<>();
/** Lignes d'écriture associées */
/** Méthode métier pour obtenir le numéro formaté */ @JsonIgnore
public String getNumeroFormate() { @OneToMany(mappedBy = "compteComptable", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
return String.format("%-10s", numeroCompte); @Builder.Default
} private List<LigneEcriture> lignesEcriture = new ArrayList<>();
/** Méthode métier pour vérifier si c'est un compte de trésorerie */ /** Méthode métier pour obtenir le numéro formaté */
public boolean isTresorerie() { public String getNumeroFormate() {
return TypeCompteComptable.TRESORERIE.equals(typeCompte); return String.format("%-10s", numeroCompte);
} }
/** Callback JPA avant la persistance */ /** Méthode métier pour vérifier si c'est un compte de trésorerie */
@PrePersist public boolean isTresorerie() {
protected void onCreate() { return TypeCompteComptable.TRESORERIE.equals(typeCompte);
super.onCreate(); }
if (soldeInitial == null) {
soldeInitial = BigDecimal.ZERO; /** Callback JPA avant la persistance */
} @PrePersist
if (soldeActuel == null) { protected void onCreate() {
soldeActuel = soldeInitial; super.onCreate();
} if (soldeInitial == null) {
if (compteCollectif == null) { soldeInitial = BigDecimal.ZERO;
compteCollectif = false; }
} if (soldeActuel == null) {
if (compteAnalytique == null) { soldeActuel = soldeInitial;
compteAnalytique = false; }
} if (compteCollectif == null) {
} compteCollectif = false;
} }
if (compteAnalytique == null) {
compteAnalytique = false;
}
}
}

View File

@@ -1,105 +1,105 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.wave.StatutCompteWave; import dev.lions.unionflow.server.api.enums.wave.StatutCompteWave;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité CompteWave pour la gestion des comptes Wave Mobile Money * Entité CompteWave pour la gestion des comptes Wave Mobile Money
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table(name = "comptes_wave", indexes = { @Table(name = "comptes_wave", indexes = {
@Index(name = "idx_compte_wave_telephone", columnList = "numero_telephone", unique = true), @Index(name = "idx_compte_wave_telephone", columnList = "numero_telephone", unique = true),
@Index(name = "idx_compte_wave_statut", columnList = "statut_compte"), @Index(name = "idx_compte_wave_statut", columnList = "statut_compte"),
@Index(name = "idx_compte_wave_organisation", columnList = "organisation_id"), @Index(name = "idx_compte_wave_organisation", columnList = "organisation_id"),
@Index(name = "idx_compte_wave_membre", columnList = "membre_id") @Index(name = "idx_compte_wave_membre", columnList = "membre_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class CompteWave extends BaseEntity { public class CompteWave extends BaseEntity {
/** Numéro de téléphone Wave (format +225XXXXXXXX) */ /** Numéro de téléphone Wave (format +225XXXXXXXX) */
@NotBlank @NotBlank
@Pattern(regexp = "^\\+225[0-9]{8}$", message = "Le numéro de téléphone Wave doit être au format +225XXXXXXXX") @Pattern(regexp = "^\\+225[0-9]{8}$", message = "Le numéro de téléphone Wave doit être au format +225XXXXXXXX")
@Column(name = "numero_telephone", unique = true, nullable = false, length = 13) @Column(name = "numero_telephone", unique = true, nullable = false, length = 13)
private String numeroTelephone; private String numeroTelephone;
/** Statut du compte */ /** Statut du compte */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "statut_compte", nullable = false, length = 30) @Column(name = "statut_compte", nullable = false, length = 30)
private StatutCompteWave statutCompte = StatutCompteWave.NON_VERIFIE; private StatutCompteWave statutCompte = StatutCompteWave.NON_VERIFIE;
/** Identifiant Wave API (encrypté) */ /** Identifiant Wave API (encrypté) */
@Column(name = "wave_account_id", length = 255) @Column(name = "wave_account_id", length = 255)
private String waveAccountId; private String waveAccountId;
/** Clé API Wave (encryptée) */ /** Clé API Wave (encryptée) */
@Column(name = "wave_api_key", length = 500) @Column(name = "wave_api_key", length = 500)
private String waveApiKey; private String waveApiKey;
/** Environnement (SANDBOX ou PRODUCTION) */ /** Environnement (SANDBOX ou PRODUCTION) */
@Column(name = "environnement", length = 20) @Column(name = "environnement", length = 20)
private String environnement; private String environnement;
/** Date de dernière vérification */ /** Date de dernière vérification */
@Column(name = "date_derniere_verification") @Column(name = "date_derniere_verification")
private java.time.LocalDateTime dateDerniereVerification; private java.time.LocalDateTime dateDerniereVerification;
/** Commentaires */ /** Commentaires */
@Column(name = "commentaire", length = 500) @Column(name = "commentaire", length = 500)
private String commentaire; private String commentaire;
// Relations // Relations
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id") @JoinColumn(name = "membre_id")
private Membre membre; private Membre membre;
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "compteWave", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "compteWave", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<TransactionWave> transactions = new ArrayList<>(); private List<TransactionWave> transactions = new ArrayList<>();
/** Méthode métier pour vérifier si le compte est vérifié */ /** Méthode métier pour vérifier si le compte est vérifié */
public boolean isVerifie() { public boolean isVerifie() {
return StatutCompteWave.VERIFIE.equals(statutCompte); return StatutCompteWave.VERIFIE.equals(statutCompte);
} }
/** Méthode métier pour vérifier si le compte peut être utilisé */ /** Méthode métier pour vérifier si le compte peut être utilisé */
public boolean peutEtreUtilise() { public boolean peutEtreUtilise() {
return StatutCompteWave.VERIFIE.equals(statutCompte); return StatutCompteWave.VERIFIE.equals(statutCompte);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (statutCompte == null) { if (statutCompte == null) {
statutCompte = StatutCompteWave.NON_VERIFIE; statutCompte = StatutCompteWave.NON_VERIFIE;
} }
if (environnement == null || environnement.isEmpty()) { if (environnement == null || environnement.isEmpty()) {
environnement = "SANDBOX"; environnement = "SANDBOX";
} }
} }
} }

View File

@@ -1,53 +1,53 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Configuration pour la gestion de la configuration système * Entité Configuration pour la gestion de la configuration système
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
*/ */
@Entity @Entity
@Table(name = "configurations") @Table(name = "configurations")
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Configuration extends BaseEntity { public class Configuration extends BaseEntity {
@NotBlank @NotBlank
@Column(name = "cle", nullable = false, unique = true, length = 255) @Column(name = "cle", nullable = false, unique = true, length = 255)
private String cle; private String cle;
@Column(name = "valeur", columnDefinition = "TEXT") @Column(name = "valeur", columnDefinition = "TEXT")
private String valeur; private String valeur;
@Column(name = "type", length = 50) @Column(name = "type", length = 50)
private String type; // STRING, NUMBER, BOOLEAN, JSON, DATE private String type; // STRING, NUMBER, BOOLEAN, JSON, DATE
@Column(name = "categorie", length = 50) @Column(name = "categorie", length = 50)
private String categorie; // SYSTEME, SECURITE, NOTIFICATION, INTEGRATION, APPEARANCE private String categorie; // SYSTEME, SECURITE, NOTIFICATION, INTEGRATION, APPEARANCE
@Column(name = "description", length = 1000) @Column(name = "description", length = 1000)
private String description; private String description;
@Column(name = "modifiable") @Column(name = "modifiable")
@Builder.Default @Builder.Default
private Boolean modifiable = true; private Boolean modifiable = true;
@Column(name = "visible") @Column(name = "visible")
@Builder.Default @Builder.Default
private Boolean visible = true; private Boolean visible = true;
@Column(name = "metadonnees", columnDefinition = "TEXT") @Column(name = "metadonnees", columnDefinition = "TEXT")
private String metadonnees; // JSON string pour stocker les métadonnées private String metadonnees; // JSON string pour stocker les métadonnées
} }

View File

@@ -1,69 +1,69 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité ConfigurationWave pour la configuration de l'intégration Wave * Entité ConfigurationWave pour la configuration de l'intégration Wave
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "configurations_wave", name = "configurations_wave",
indexes = { indexes = {
@Index(name = "idx_config_wave_cle", columnList = "cle", unique = true) @Index(name = "idx_config_wave_cle", columnList = "cle", unique = true)
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ConfigurationWave extends BaseEntity { public class ConfigurationWave extends BaseEntity {
/** Clé de configuration */ /** Clé de configuration */
@NotBlank @NotBlank
@Column(name = "cle", unique = true, nullable = false, length = 100) @Column(name = "cle", unique = true, nullable = false, length = 100)
private String cle; private String cle;
/** Valeur de configuration (peut être encryptée) */ /** Valeur de configuration (peut être encryptée) */
@Column(name = "valeur", columnDefinition = "TEXT") @Column(name = "valeur", columnDefinition = "TEXT")
private String valeur; private String valeur;
/** Description de la configuration */ /** Description de la configuration */
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** Type de valeur (STRING, NUMBER, BOOLEAN, JSON, ENCRYPTED) */ /** Type de valeur (STRING, NUMBER, BOOLEAN, JSON, ENCRYPTED) */
@Column(name = "type_valeur", length = 20) @Column(name = "type_valeur", length = 20)
private String typeValeur; private String typeValeur;
/** Environnement (SANDBOX, PRODUCTION, COMMON) */ /** Environnement (SANDBOX, PRODUCTION, COMMON) */
@Column(name = "environnement", length = 20) @Column(name = "environnement", length = 20)
private String environnement; private String environnement;
/** Méthode métier pour vérifier si la valeur est encryptée */ /** Méthode métier pour vérifier si la valeur est encryptée */
public boolean isEncryptee() { public boolean isEncryptee() {
return "ENCRYPTED".equals(typeValeur); return "ENCRYPTED".equals(typeValeur);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (typeValeur == null || typeValeur.isEmpty()) { if (typeValeur == null || typeValeur.isEmpty()) {
typeValeur = "STRING"; typeValeur = "STRING";
} }
if (environnement == null || environnement.isEmpty()) { if (environnement == null || environnement.isEmpty()) {
environnement = "COMMON"; environnement = "COMMON";
} }
} }
} }

View File

@@ -1,194 +1,194 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Cotisation avec UUID Représente une cotisation d'un membre à son * Entité Cotisation avec UUID Représente une cotisation d'un membre à son
* organisation * organisation
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 2.0 * @version 2.0
* @since 2025-01-16 * @since 2025-01-16
*/ */
@Entity @Entity
@Table(name = "cotisations", indexes = { @Table(name = "cotisations", indexes = {
@Index(name = "idx_cotisation_membre", columnList = "membre_id"), @Index(name = "idx_cotisation_membre", columnList = "membre_id"),
@Index(name = "idx_cotisation_reference", columnList = "numero_reference", unique = true), @Index(name = "idx_cotisation_reference", columnList = "numero_reference", unique = true),
@Index(name = "idx_cotisation_statut", columnList = "statut"), @Index(name = "idx_cotisation_statut", columnList = "statut"),
@Index(name = "idx_cotisation_echeance", columnList = "date_echeance"), @Index(name = "idx_cotisation_echeance", columnList = "date_echeance"),
@Index(name = "idx_cotisation_type", columnList = "type_cotisation"), @Index(name = "idx_cotisation_type", columnList = "type_cotisation"),
@Index(name = "idx_cotisation_annee_mois", columnList = "annee, mois") @Index(name = "idx_cotisation_annee_mois", columnList = "annee, mois")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Cotisation extends BaseEntity { public class Cotisation extends BaseEntity {
@NotBlank @NotBlank
@Column(name = "numero_reference", unique = true, nullable = false, length = 50) @Column(name = "numero_reference", unique = true, nullable = false, length = 50)
private String numeroReference; private String numeroReference;
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false) @JoinColumn(name = "membre_id", nullable = false)
private Membre membre; private Membre membre;
/** Organisation pour laquelle la cotisation est due */ /** Organisation pour laquelle la cotisation est due */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
/** Intention de paiement Wave associée (null si cotisation en attente) */ /** Intention de paiement Wave associée (null si cotisation en attente) */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "intention_paiement_id") @JoinColumn(name = "intention_paiement_id")
private IntentionPaiement intentionPaiement; private IntentionPaiement intentionPaiement;
@NotBlank @NotBlank
@Column(name = "type_cotisation", nullable = false, length = 50) @Column(name = "type_cotisation", nullable = false, length = 50)
private String typeCotisation; private String typeCotisation;
@NotBlank @NotBlank
@Column(name = "libelle", nullable = false, length = 100) @Column(name = "libelle", nullable = false, length = 100)
private String libelle; private String libelle;
@NotNull @NotNull
@DecimalMin(value = "0.0", message = "Le montant dû doit être positif") @DecimalMin(value = "0.0", message = "Le montant dû doit être positif")
@Digits(integer = 10, fraction = 2) @Digits(integer = 10, fraction = 2)
@Column(name = "montant_du", nullable = false, precision = 12, scale = 2) @Column(name = "montant_du", nullable = false, precision = 12, scale = 2)
private BigDecimal montantDu; private BigDecimal montantDu;
@Builder.Default @Builder.Default
@DecimalMin(value = "0.0", message = "Le montant payé doit être positif") @DecimalMin(value = "0.0", message = "Le montant payé doit être positif")
@Digits(integer = 10, fraction = 2) @Digits(integer = 10, fraction = 2)
@Column(name = "montant_paye", nullable = false, precision = 12, scale = 2) @Column(name = "montant_paye", nullable = false, precision = 12, scale = 2)
private BigDecimal montantPaye = BigDecimal.ZERO; private BigDecimal montantPaye = BigDecimal.ZERO;
@NotBlank @NotBlank
@Pattern(regexp = "^[A-Z]{3}$", message = "Le code devise doit être un code ISO à 3 lettres") @Pattern(regexp = "^[A-Z]{3}$", message = "Le code devise doit être un code ISO à 3 lettres")
@Column(name = "code_devise", nullable = false, length = 3) @Column(name = "code_devise", nullable = false, length = 3)
private String codeDevise; private String codeDevise;
@NotBlank @NotBlank
@Pattern(regexp = "^(EN_ATTENTE|PAYEE|EN_RETARD|PARTIELLEMENT_PAYEE|ANNULEE)$") @Pattern(regexp = "^(EN_ATTENTE|PAYEE|EN_RETARD|PARTIELLEMENT_PAYEE|ANNULEE)$")
@Column(name = "statut", nullable = false, length = 30) @Column(name = "statut", nullable = false, length = 30)
private String statut; private String statut;
@NotNull @NotNull
@Column(name = "date_echeance", nullable = false) @Column(name = "date_echeance", nullable = false)
private LocalDate dateEcheance; private LocalDate dateEcheance;
@Column(name = "date_paiement") @Column(name = "date_paiement")
private LocalDateTime datePaiement; private LocalDateTime datePaiement;
@Size(max = 500) @Size(max = 500)
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
@Size(max = 20) @Size(max = 20)
@Column(name = "periode", length = 20) @Column(name = "periode", length = 20)
private String periode; private String periode;
@NotNull @NotNull
@Min(value = 2020, message = "L'année doit être supérieure à 2020") @Min(value = 2020, message = "L'année doit être supérieure à 2020")
@Max(value = 2100, message = "L'année doit être inférieure à 2100") @Max(value = 2100, message = "L'année doit être inférieure à 2100")
@Column(name = "annee", nullable = false) @Column(name = "annee", nullable = false)
private Integer annee; private Integer annee;
@Min(value = 1, message = "Le mois doit être entre 1 et 12") @Min(value = 1, message = "Le mois doit être entre 1 et 12")
@Max(value = 12, message = "Le mois doit être entre 1 et 12") @Max(value = 12, message = "Le mois doit être entre 1 et 12")
@Column(name = "mois") @Column(name = "mois")
private Integer mois; private Integer mois;
@Size(max = 1000) @Size(max = 1000)
@Column(name = "observations", length = 1000) @Column(name = "observations", length = 1000)
private String observations; private String observations;
@Builder.Default @Builder.Default
@Column(name = "recurrente", nullable = false) @Column(name = "recurrente", nullable = false)
private Boolean recurrente = false; private Boolean recurrente = false;
@Builder.Default @Builder.Default
@Min(value = 0, message = "Le nombre de rappels doit être positif") @Min(value = 0, message = "Le nombre de rappels doit être positif")
@Column(name = "nombre_rappels", nullable = false) @Column(name = "nombre_rappels", nullable = false)
private Integer nombreRappels = 0; private Integer nombreRappels = 0;
@Column(name = "date_dernier_rappel") @Column(name = "date_dernier_rappel")
private LocalDateTime dateDernierRappel; private LocalDateTime dateDernierRappel;
@Column(name = "valide_par_id") @Column(name = "valide_par_id")
private UUID valideParId; private UUID valideParId;
@Size(max = 100) @Size(max = 100)
@Column(name = "nom_validateur", length = 100) @Column(name = "nom_validateur", length = 100)
private String nomValidateur; private String nomValidateur;
@Column(name = "date_validation") @Column(name = "date_validation")
private LocalDateTime dateValidation; private LocalDateTime dateValidation;
/** Méthode métier pour calculer le montant restant à payer */ /** Méthode métier pour calculer le montant restant à payer */
public BigDecimal getMontantRestant() { public BigDecimal getMontantRestant() {
if (montantDu == null || montantPaye == null) { if (montantDu == null || montantPaye == null) {
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
return montantDu.subtract(montantPaye); return montantDu.subtract(montantPaye);
} }
/** Méthode métier pour vérifier si la cotisation est entièrement payée */ /** Méthode métier pour vérifier si la cotisation est entièrement payée */
public boolean isEntierementPayee() { public boolean isEntierementPayee() {
return getMontantRestant().compareTo(BigDecimal.ZERO) <= 0; return getMontantRestant().compareTo(BigDecimal.ZERO) <= 0;
} }
/** Méthode métier pour vérifier si la cotisation est en retard */ /** Méthode métier pour vérifier si la cotisation est en retard */
public boolean isEnRetard() { public boolean isEnRetard() {
return dateEcheance != null && dateEcheance.isBefore(LocalDate.now()) && !isEntierementPayee(); return dateEcheance != null && dateEcheance.isBefore(LocalDate.now()) && !isEntierementPayee();
} }
private static final AtomicLong REFERENCE_COUNTER = private static final AtomicLong REFERENCE_COUNTER =
new AtomicLong(System.currentTimeMillis() % 100000000L); new AtomicLong(System.currentTimeMillis() % 100000000L);
/** Méthode métier pour générer un numéro de référence unique */ /** Méthode métier pour générer un numéro de référence unique */
public static String genererNumeroReference() { public static String genererNumeroReference() {
return "COT-" return "COT-"
+ LocalDate.now().getYear() + LocalDate.now().getYear()
+ "-" + "-"
+ String.format("%08d", REFERENCE_COUNTER.incrementAndGet() % 100000000L); + String.format("%08d", REFERENCE_COUNTER.incrementAndGet() % 100000000L);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); // Appelle le onCreate de BaseEntity super.onCreate(); // Appelle le onCreate de BaseEntity
if (numeroReference == null || numeroReference.isEmpty()) { if (numeroReference == null || numeroReference.isEmpty()) {
numeroReference = genererNumeroReference(); numeroReference = genererNumeroReference();
} }
if (codeDevise == null) { if (codeDevise == null) {
codeDevise = "XOF"; codeDevise = "XOF";
} }
if (statut == null) { if (statut == null) {
statut = "EN_ATTENTE"; statut = "EN_ATTENTE";
} }
if (montantPaye == null) { if (montantPaye == null) {
montantPaye = BigDecimal.ZERO; montantPaye = BigDecimal.ZERO;
} }
if (nombreRappels == null) { if (nombreRappels == null) {
nombreRappels = 0; nombreRappels = 0;
} }
if (recurrente == null) { if (recurrente == null) {
recurrente = false; recurrente = false;
} }
} }
} }

View File

@@ -1,132 +1,132 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import lombok.*; import lombok.*;
/** /**
* Demande d'adhésion d'un utilisateur à une organisation. * Demande d'adhésion d'un utilisateur à une organisation.
* *
* <p>Flux : * <p>Flux :
* <ol> * <ol>
* <li>L'utilisateur crée son compte et choisit une organisation</li> * <li>L'utilisateur crée son compte et choisit une organisation</li>
* <li>Une {@code DemandeAdhesion} est créée (statut EN_ATTENTE)</li> * <li>Une {@code DemandeAdhesion} est créée (statut EN_ATTENTE)</li>
* <li>Si frais d'adhésion : une {@link IntentionPaiement} est créée et liée</li> * <li>Si frais d'adhésion : une {@link IntentionPaiement} est créée et liée</li>
* <li>Le manager valide → {@link MembreOrganisation} créé, quota souscription décrémenté</li> * <li>Le manager valide → {@link MembreOrganisation} créé, quota souscription décrémenté</li>
* </ol> * </ol>
* *
* <p>Remplace l'ancienne entité {@code Adhesion}. * <p>Remplace l'ancienne entité {@code Adhesion}.
* Table : {@code demandes_adhesion} * Table : {@code demandes_adhesion}
*/ */
@Entity @Entity
@Table( @Table(
name = "demandes_adhesion", name = "demandes_adhesion",
indexes = { indexes = {
@Index(name = "idx_da_utilisateur", columnList = "utilisateur_id"), @Index(name = "idx_da_utilisateur", columnList = "utilisateur_id"),
@Index(name = "idx_da_organisation", columnList = "organisation_id"), @Index(name = "idx_da_organisation", columnList = "organisation_id"),
@Index(name = "idx_da_statut", columnList = "statut"), @Index(name = "idx_da_statut", columnList = "statut"),
@Index(name = "idx_da_date", columnList = "date_demande") @Index(name = "idx_da_date", columnList = "date_demande")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class DemandeAdhesion extends BaseEntity { public class DemandeAdhesion extends BaseEntity {
@NotBlank @NotBlank
@Column(name = "numero_reference", unique = true, nullable = false, length = 50) @Column(name = "numero_reference", unique = true, nullable = false, length = 50)
private String numeroReference; private String numeroReference;
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "utilisateur_id", nullable = false) @JoinColumn(name = "utilisateur_id", nullable = false)
private Membre utilisateur; private Membre utilisateur;
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotBlank @NotBlank
@Pattern(regexp = "^(EN_ATTENTE|APPROUVEE|REJETEE|ANNULEE)$") @Pattern(regexp = "^(EN_ATTENTE|APPROUVEE|REJETEE|ANNULEE)$")
@Builder.Default @Builder.Default
@Column(name = "statut", nullable = false, length = 20) @Column(name = "statut", nullable = false, length = 20)
private String statut = "EN_ATTENTE"; private String statut = "EN_ATTENTE";
@Builder.Default @Builder.Default
@DecimalMin("0.00") @DecimalMin("0.00")
@Digits(integer = 10, fraction = 2) @Digits(integer = 10, fraction = 2)
@Column(name = "frais_adhesion", nullable = false, precision = 12, scale = 2) @Column(name = "frais_adhesion", nullable = false, precision = 12, scale = 2)
private BigDecimal fraisAdhesion = BigDecimal.ZERO; private BigDecimal fraisAdhesion = BigDecimal.ZERO;
@Builder.Default @Builder.Default
@DecimalMin("0.00") @DecimalMin("0.00")
@Digits(integer = 10, fraction = 2) @Digits(integer = 10, fraction = 2)
@Column(name = "montant_paye", nullable = false, precision = 12, scale = 2) @Column(name = "montant_paye", nullable = false, precision = 12, scale = 2)
private BigDecimal montantPaye = BigDecimal.ZERO; private BigDecimal montantPaye = BigDecimal.ZERO;
@Builder.Default @Builder.Default
@Pattern(regexp = "^[A-Z]{3}$") @Pattern(regexp = "^[A-Z]{3}$")
@Column(name = "code_devise", nullable = false, length = 3) @Column(name = "code_devise", nullable = false, length = 3)
private String codeDevise = "XOF"; private String codeDevise = "XOF";
/** Intention de paiement Wave liée aux frais d'adhésion */ /** Intention de paiement Wave liée aux frais d'adhésion */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "intention_paiement_id") @JoinColumn(name = "intention_paiement_id")
private IntentionPaiement intentionPaiement; private IntentionPaiement intentionPaiement;
@Builder.Default @Builder.Default
@Column(name = "date_demande", nullable = false) @Column(name = "date_demande", nullable = false)
private LocalDateTime dateDemande = LocalDateTime.now(); private LocalDateTime dateDemande = LocalDateTime.now();
@Column(name = "date_traitement") @Column(name = "date_traitement")
private LocalDateTime dateTraitement; private LocalDateTime dateTraitement;
/** Manager/Admin qui a approuvé ou rejeté */ /** Manager/Admin qui a approuvé ou rejeté */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "traite_par_id") @JoinColumn(name = "traite_par_id")
private Membre traitePar; private Membre traitePar;
@Column(name = "motif_rejet", length = 1000) @Column(name = "motif_rejet", length = 1000)
private String motifRejet; private String motifRejet;
@Column(name = "observations", length = 1000) @Column(name = "observations", length = 1000)
private String observations; private String observations;
// ── Méthodes métier ──────────────────────────────────────────────────────── // ── Méthodes métier ────────────────────────────────────────────────────────
public boolean isEnAttente() { return "EN_ATTENTE".equals(statut); } public boolean isEnAttente() { return "EN_ATTENTE".equals(statut); }
public boolean isApprouvee() { return "APPROUVEE".equals(statut); } public boolean isApprouvee() { return "APPROUVEE".equals(statut); }
public boolean isRejetee() { return "REJETEE".equals(statut); } public boolean isRejetee() { return "REJETEE".equals(statut); }
public boolean isPayeeIntegralement() { public boolean isPayeeIntegralement() {
return fraisAdhesion != null return fraisAdhesion != null
&& montantPaye != null && montantPaye != null
&& montantPaye.compareTo(fraisAdhesion) >= 0; && montantPaye.compareTo(fraisAdhesion) >= 0;
} }
private static final AtomicLong REFERENCE_COUNTER = private static final AtomicLong REFERENCE_COUNTER =
new AtomicLong(System.currentTimeMillis() % 100000000L); new AtomicLong(System.currentTimeMillis() % 100000000L);
public static String genererNumeroReference() { public static String genererNumeroReference() {
return "ADH-" + java.time.LocalDate.now().getYear() return "ADH-" + java.time.LocalDate.now().getYear()
+ "-" + String.format("%08d", REFERENCE_COUNTER.incrementAndGet() % 100000000L); + "-" + String.format("%08d", REFERENCE_COUNTER.incrementAndGet() % 100000000L);
} }
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (dateDemande == null) dateDemande = LocalDateTime.now(); if (dateDemande == null) dateDemande = LocalDateTime.now();
if (statut == null) statut = "EN_ATTENTE"; if (statut == null) statut = "EN_ATTENTE";
if (codeDevise == null) codeDevise = "XOF"; if (codeDevise == null) codeDevise = "XOF";
if (fraisAdhesion == null) fraisAdhesion = BigDecimal.ZERO; if (fraisAdhesion == null) fraisAdhesion = BigDecimal.ZERO;
if (montantPaye == null) montantPaye = BigDecimal.ZERO; if (montantPaye == null) montantPaye = BigDecimal.ZERO;
if (numeroReference == null || numeroReference.isEmpty()) { if (numeroReference == null || numeroReference.isEmpty()) {
numeroReference = genererNumeroReference(); numeroReference = genererNumeroReference();
} }
} }
} }

View File

@@ -1,130 +1,177 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.solidarite.StatutAide; import dev.lions.unionflow.server.api.enums.solidarite.StatutAide;
import dev.lions.unionflow.server.api.enums.solidarite.TypeAide; import dev.lions.unionflow.server.api.enums.solidarite.TypeAide;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** Entité représentant une demande d'aide dans le système de solidarité */ /** Entité représentant une demande d'aide dans le système de solidarité */
@Entity @Entity
@Table(name = "demandes_aide") @Table(name = "demandes_aide")
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class DemandeAide extends BaseEntity { public class DemandeAide extends BaseEntity {
@Column(name = "titre", nullable = false, length = 200) @Column(name = "titre", nullable = false, length = 200)
private String titre; private String titre;
@Column(name = "description", nullable = false, columnDefinition = "TEXT") @Column(name = "description", nullable = false, columnDefinition = "TEXT")
private String description; private String description;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_aide", nullable = false) @Column(name = "type_aide", nullable = false)
private TypeAide typeAide; private TypeAide typeAide;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false) @Column(name = "statut", nullable = false)
private StatutAide statut; private StatutAide statut;
@Column(name = "montant_demande", precision = 10, scale = 2) @Column(name = "montant_demande", precision = 10, scale = 2)
private BigDecimal montantDemande; private BigDecimal montantDemande;
@Column(name = "montant_approuve", precision = 10, scale = 2) @Column(name = "montant_approuve", precision = 10, scale = 2)
private BigDecimal montantApprouve; private BigDecimal montantApprouve;
@Column(name = "date_demande", nullable = false) @Column(name = "date_demande", nullable = false)
private LocalDateTime dateDemande; private LocalDateTime dateDemande;
@Column(name = "date_evaluation") @Column(name = "date_evaluation")
private LocalDateTime dateEvaluation; private LocalDateTime dateEvaluation;
@Column(name = "date_versement") @Column(name = "date_versement")
private LocalDateTime dateVersement; private LocalDateTime dateVersement;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "demandeur_id", nullable = false) @JoinColumn(name = "demandeur_id", nullable = false)
private Membre demandeur; private Membre demandeur;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "evaluateur_id") @JoinColumn(name = "evaluateur_id")
private Membre evaluateur; private Membre evaluateur;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@Column(name = "justification", columnDefinition = "TEXT") @Column(name = "justification", columnDefinition = "TEXT")
private String justification; private String justification;
@Column(name = "commentaire_evaluation", columnDefinition = "TEXT") @Column(name = "commentaire_evaluation", columnDefinition = "TEXT")
private String commentaireEvaluation; private String commentaireEvaluation;
@Column(name = "urgence", nullable = false) @Column(name = "urgence", nullable = false)
@Builder.Default @Builder.Default
private Boolean urgence = false; private Boolean urgence = false;
@Column(name = "documents_fournis") @Column(name = "documents_fournis")
private String documentsFournis; private String documentsFournis;
@PrePersist // ========================================================
protected void onCreate() { // Workflow v2 (P1-NEW-3, 2026-04-25) — DEPOSE → ENQUETE → AVIS_COMITE → DECISION_CA → PAYE → CLOTURE
super.onCreate(); // Appelle le onCreate de BaseEntity // ========================================================
if (dateDemande == null) {
dateDemande = LocalDateTime.now(); /** Étape actuelle dans le workflow v2 (DEPOSE par défaut). */
} @Column(name = "etape", length = 30)
if (statut == null) { @Builder.Default
statut = StatutAide.EN_ATTENTE; private String etape = "DEPOSE";
}
if (urgence == null) { /** Animateur de zone responsable de l'enquête sociale (étape ENQUETE). */
urgence = false; @Column(name = "animateur_zone_id")
} private java.util.UUID animateurZoneId;
}
/** Rapport rédigé par l'animateur après visite (étape ENQUETE). */
@PreUpdate @Column(name = "rapport_enquete_sociale", columnDefinition = "TEXT")
protected void onUpdate() { private String rapportEnqueteSociale;
// Méthode appelée avant mise à jour
} @Column(name = "date_enquete")
private LocalDateTime dateEnquete;
/** Vérifie si la demande est en attente */
public boolean isEnAttente() { /** Géolocalisation GPS de l'enquête (preuve de visite terrain). */
return StatutAide.EN_ATTENTE.equals(statut); @Column(name = "gps_enquete_lat", precision = 10, scale = 7)
} private java.math.BigDecimal gpsEnqueteLat;
/** Vérifie si la demande est approuvée */ @Column(name = "gps_enquete_lon", precision = 10, scale = 7)
public boolean isApprouvee() { private java.math.BigDecimal gpsEnqueteLon;
return StatutAide.APPROUVEE.equals(statut);
} /** Avis du comité social ou commission solidarité (étape AVIS_COMITE). */
@Column(name = "avis_comite_social", columnDefinition = "TEXT")
/** Vérifie si la demande est rejetée */ private String avisComiteSocial;
public boolean isRejetee() {
return StatutAide.REJETEE.equals(statut); @Column(name = "date_avis_comite")
} private LocalDateTime dateAvisComite;
/** Vérifie si la demande est urgente */ /** Lien vers le PV CA dans lequel la décision a été votée (étape DECISION_CA). */
public boolean isUrgente() { @Column(name = "decision_ca_id")
return Boolean.TRUE.equals(urgence); private java.util.UUID decisionCaId;
}
@Column(name = "date_decision_ca")
/** Calcule le pourcentage d'approbation par rapport au montant demandé */ private LocalDateTime dateDecisionCa;
public BigDecimal getPourcentageApprobation() {
if (montantDemande == null || montantDemande.compareTo(BigDecimal.ZERO) == 0) { @Column(name = "date_paie")
return BigDecimal.ZERO; private LocalDateTime datePaie;
}
if (montantApprouve == null) { @Column(name = "reference_paiement", length = 100)
return BigDecimal.ZERO; private String referencePaiement;
}
return montantApprouve @PrePersist
.divide(montantDemande, 4, RoundingMode.HALF_UP) protected void onCreate() {
.multiply(BigDecimal.valueOf(100)); super.onCreate(); // Appelle le onCreate de BaseEntity
} if (dateDemande == null) {
} dateDemande = LocalDateTime.now();
}
if (statut == null) {
statut = StatutAide.EN_ATTENTE;
}
if (urgence == null) {
urgence = false;
}
}
@PreUpdate
protected void onUpdate() {
// Méthode appelée avant mise à jour
}
/** Vérifie si la demande est en attente */
public boolean isEnAttente() {
return StatutAide.EN_ATTENTE.equals(statut);
}
/** Vérifie si la demande est approuvée */
public boolean isApprouvee() {
return StatutAide.APPROUVEE.equals(statut);
}
/** Vérifie si la demande est rejetée */
public boolean isRejetee() {
return StatutAide.REJETEE.equals(statut);
}
/** Vérifie si la demande est urgente */
public boolean isUrgente() {
return Boolean.TRUE.equals(urgence);
}
/** Calcule le pourcentage d'approbation par rapport au montant demandé */
public BigDecimal getPourcentageApprobation() {
if (montantDemande == null || montantDemande.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
if (montantApprouve == null) {
return BigDecimal.ZERO;
}
return montantApprouve
.divide(montantDemande, 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100));
}
}

View File

@@ -0,0 +1,58 @@
package dev.lions.unionflow.server.entity;
import java.util.Set;
/**
* Devises supportées par UnionFlow.
*
* <p>UnionFlow vise prioritairement la zone UEMOA (XOF/XAF) mais s'ouvre à la diaspora
* (EUR/USD/GBP/CAD). Le {@link ZoneDevise} permet de discriminer pour les règles
* AML (transferts internationaux, due diligence renforcée).
*
* @since 2026-04-25 (P2-NEW-7)
*/
public enum Devise {
// Zone UEMOA / CEMAC
XOF("Franc CFA Ouest", ZoneDevise.UEMOA),
XAF("Franc CFA Centrale", ZoneDevise.CEMAC),
// Diaspora — Europe / Amérique
EUR("Euro", ZoneDevise.EUROPE),
USD("Dollar US", ZoneDevise.AMERIQUE),
GBP("Livre Sterling", ZoneDevise.EUROPE),
CAD("Dollar Canadien", ZoneDevise.AMERIQUE),
CHF("Franc Suisse", ZoneDevise.EUROPE),
// CEDEAO non-UEMOA (pour intégrations futures)
GHS("Cédi Ghanéen", ZoneDevise.CEDEAO),
NGN("Naira Nigérian", ZoneDevise.CEDEAO),
// Maghreb
MAD("Dirham Marocain", ZoneDevise.MAGHREB);
private final String libelle;
private final ZoneDevise zone;
Devise(String libelle, ZoneDevise zone) {
this.libelle = libelle;
this.zone = zone;
}
public String libelle() { return libelle; }
public ZoneDevise zone() { return zone; }
/** Devise de référence UnionFlow / BCEAO. */
public static Devise reference() { return XOF; }
/** Devises pour lesquelles un transfert depuis/vers UEMOA déclenche AML renforcé. */
public static final Set<Devise> DEVISES_INTERNATIONALES = Set.of(EUR, USD, GBP, CAD, CHF);
public boolean estInternationale() {
return DEVISES_INTERNATIONALES.contains(this);
}
public enum ZoneDevise {
UEMOA, CEMAC, CEDEAO, EUROPE, AMERIQUE, MAGHREB
}
}

View File

@@ -1,130 +1,130 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.document.TypeDocument; import dev.lions.unionflow.server.api.enums.document.TypeDocument;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Document pour la gestion documentaire sécurisée * Entité Document pour la gestion documentaire sécurisée
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "documents", name = "documents",
indexes = { indexes = {
@Index(name = "idx_document_nom_fichier", columnList = "nom_fichier"), @Index(name = "idx_document_nom_fichier", columnList = "nom_fichier"),
@Index(name = "idx_document_type", columnList = "type_document"), @Index(name = "idx_document_type", columnList = "type_document"),
@Index(name = "idx_document_hash_md5", columnList = "hash_md5"), @Index(name = "idx_document_hash_md5", columnList = "hash_md5"),
@Index(name = "idx_document_hash_sha256", columnList = "hash_sha256") @Index(name = "idx_document_hash_sha256", columnList = "hash_sha256")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Document extends BaseEntity { public class Document extends BaseEntity {
/** Nom du fichier original */ /** Nom du fichier original */
@NotBlank @NotBlank
@Column(name = "nom_fichier", nullable = false, length = 255) @Column(name = "nom_fichier", nullable = false, length = 255)
private String nomFichier; private String nomFichier;
/** Nom original du fichier (tel que téléchargé) */ /** Nom original du fichier (tel que téléchargé) */
@Column(name = "nom_original", length = 255) @Column(name = "nom_original", length = 255)
private String nomOriginal; private String nomOriginal;
/** Chemin de stockage */ /** Chemin de stockage */
@NotBlank @NotBlank
@Column(name = "chemin_stockage", nullable = false, length = 1000) @Column(name = "chemin_stockage", nullable = false, length = 1000)
private String cheminStockage; private String cheminStockage;
/** Type MIME */ /** Type MIME */
@Column(name = "type_mime", length = 100) @Column(name = "type_mime", length = 100)
private String typeMime; private String typeMime;
/** Taille du fichier en octets */ /** Taille du fichier en octets */
@NotNull @NotNull
@Min(value = 0, message = "La taille doit être positive") @Min(value = 0, message = "La taille doit être positive")
@Column(name = "taille_octets", nullable = false) @Column(name = "taille_octets", nullable = false)
private Long tailleOctets; private Long tailleOctets;
/** Type de document */ /** Type de document */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_document", length = 50) @Column(name = "type_document", length = 50)
private TypeDocument typeDocument; private TypeDocument typeDocument;
/** Hash MD5 pour vérification d'intégrité */ /** Hash MD5 pour vérification d'intégrité */
@Column(name = "hash_md5", length = 32) @Column(name = "hash_md5", length = 32)
private String hashMd5; private String hashMd5;
/** Hash SHA256 pour vérification d'intégrité */ /** Hash SHA256 pour vérification d'intégrité */
@Column(name = "hash_sha256", length = 64) @Column(name = "hash_sha256", length = 64)
private String hashSha256; private String hashSha256;
/** Description du document */ /** Description du document */
@Column(name = "description", length = 1000) @Column(name = "description", length = 1000)
private String description; private String description;
/** Nombre de téléchargements */ /** Nombre de téléchargements */
@Builder.Default @Builder.Default
@Column(name = "nombre_telechargements", nullable = false) @Column(name = "nombre_telechargements", nullable = false)
private Integer nombreTelechargements = 0; private Integer nombreTelechargements = 0;
/** Date de dernier téléchargement */ /** Date de dernier téléchargement */
@Column(name = "date_dernier_telechargement") @Column(name = "date_dernier_telechargement")
private java.time.LocalDateTime dateDernierTelechargement; private java.time.LocalDateTime dateDernierTelechargement;
/** Pièces jointes associées */ /** Pièces jointes associées */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "document", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "document", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<PieceJointe> piecesJointes = new ArrayList<>(); private List<PieceJointe> piecesJointes = new ArrayList<>();
/** Méthode métier pour vérifier l'intégrité avec MD5 */ /** Méthode métier pour vérifier l'intégrité avec MD5 */
public boolean verifierIntegriteMd5(String hashAttendu) { public boolean verifierIntegriteMd5(String hashAttendu) {
return hashMd5 != null && hashMd5.equalsIgnoreCase(hashAttendu); return hashMd5 != null && hashMd5.equalsIgnoreCase(hashAttendu);
} }
/** Méthode métier pour vérifier l'intégrité avec SHA256 */ /** Méthode métier pour vérifier l'intégrité avec SHA256 */
public boolean verifierIntegriteSha256(String hashAttendu) { public boolean verifierIntegriteSha256(String hashAttendu) {
return hashSha256 != null && hashSha256.equalsIgnoreCase(hashAttendu); return hashSha256 != null && hashSha256.equalsIgnoreCase(hashAttendu);
} }
/** Méthode métier pour obtenir la taille formatée */ /** Méthode métier pour obtenir la taille formatée */
public String getTailleFormatee() { public String getTailleFormatee() {
if (tailleOctets == null) { if (tailleOctets == null) {
return "0 B"; return "0 B";
} }
if (tailleOctets < 1024) { if (tailleOctets < 1024) {
return tailleOctets + " B"; return tailleOctets + " B";
} else if (tailleOctets < 1024 * 1024) { } else if (tailleOctets < 1024 * 1024) {
return String.format("%.2f KB", tailleOctets / 1024.0); return String.format("%.2f KB", tailleOctets / 1024.0);
} else { } else {
return String.format("%.2f MB", tailleOctets / (1024.0 * 1024.0)); return String.format("%.2f MB", tailleOctets / (1024.0 * 1024.0));
} }
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (nombreTelechargements == null) { if (nombreTelechargements == null) {
nombreTelechargements = 0; nombreTelechargements = 0;
} }
if (typeDocument == null) { if (typeDocument == null) {
typeDocument = TypeDocument.AUTRE; typeDocument = TypeDocument.AUTRE;
} }
} }
} }

View File

@@ -0,0 +1,86 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Don reçu (numéraire, nature, bénévolat, legs) — comptabilisé selon SYCEBNL :
*
* <ul>
* <li>NUMERAIRE → Crédit 755 (Dons et libéralités)
* <li>NATURE → valorisation obligatoire au prix marché, idem 755
* <li>BENEVOLAT → valorisation possible en notes annexes
* <li>LEGS → Crédit 756 ou poste dédié selon nature
* <li>FONDS_DEDIE → Crédit 19 (Fonds dédiés non utilisés, à reverser si finalité non remplie)
* </ul>
*
* @since 2026-04-25 (P1-NEW-13)
*/
@Entity
@Table(name = "dons_recus", indexes = {
@Index(name = "idx_don_org", columnList = "organisation_id"),
@Index(name = "idx_don_donateur", columnList = "donateur_id"),
@Index(name = "idx_don_date", columnList = "date_don")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class DonRecu extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "donateur_id")
private Donateur donateur;
@NotBlank
@Column(name = "type_don", nullable = false, length = 20)
private String typeDon; // NUMERAIRE, NATURE, BENEVOLAT, LEGS
@Column(name = "montant_xof", precision = 15, scale = 2)
private BigDecimal montantXof;
@Column(name = "valorisation_xof", precision = 15, scale = 2)
private BigDecimal valorisationXof;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@NotNull
@Column(name = "date_don", nullable = false)
private LocalDate dateDon;
@NotBlank
@Column(name = "affectation", nullable = false, length = 50)
@Builder.Default
private String affectation = "LIBRE"; // LIBRE, FONDS_DEDIE, PROJET_SPECIFIQUE
@Column(name = "fonds_dedie_id")
private UUID fondsDedieId;
@Column(name = "projet_id")
private UUID projetId;
@Column(name = "recu_emis", nullable = false)
@Builder.Default
private boolean recuEmis = false;
@Column(name = "numero_recu", length = 50)
private String numeroRecu;
@Column(name = "date_emission_recu")
private LocalDate dateEmissionRecu;
}

View File

@@ -0,0 +1,53 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Donateur — registre obligatoire pour les entités relevant de SYCEBNL (associations, ONG,
* mutuelles sociales).
*
* @since 2026-04-25 (P1-NEW-13)
*/
@Entity
@Table(name = "donateurs", indexes = {
@Index(name = "idx_donateur_org", columnList = "organisation_id"),
@Index(name = "idx_donateur_type", columnList = "type_donateur")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class Donateur extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotBlank
@Column(name = "type_donateur", nullable = false, length = 20)
private String typeDonateur; // PERSONNE_PHYSIQUE, PERSONNE_MORALE, ANONYME
@Column(name = "nom_prenoms", length = 255)
private String nomPrenoms;
@Column(name = "raison_sociale", length = 255)
private String raisonSociale;
@Column(name = "pays", length = 3)
private String pays;
@Column(name = "email", length = 255)
private String email;
@Column(name = "telephone", length = 20)
private String telephone;
}

View File

@@ -1,174 +1,174 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité EcritureComptable pour les écritures comptables * Entité EcritureComptable pour les écritures comptables
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "ecritures_comptables", name = "ecritures_comptables",
indexes = { indexes = {
@Index(name = "idx_ecriture_numero_piece", columnList = "numero_piece", unique = true), @Index(name = "idx_ecriture_numero_piece", columnList = "numero_piece", unique = true),
@Index(name = "idx_ecriture_date", columnList = "date_ecriture"), @Index(name = "idx_ecriture_date", columnList = "date_ecriture"),
@Index(name = "idx_ecriture_journal", columnList = "journal_id"), @Index(name = "idx_ecriture_journal", columnList = "journal_id"),
@Index(name = "idx_ecriture_organisation", columnList = "organisation_id"), @Index(name = "idx_ecriture_organisation", columnList = "organisation_id"),
@Index(name = "idx_ecriture_paiement", columnList = "paiement_id") @Index(name = "idx_ecriture_paiement", columnList = "paiement_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class EcritureComptable extends BaseEntity { public class EcritureComptable extends BaseEntity {
/** Numéro de pièce unique */ /** Numéro de pièce unique */
@NotBlank @NotBlank
@Column(name = "numero_piece", unique = true, nullable = false, length = 50) @Column(name = "numero_piece", unique = true, nullable = false, length = 50)
private String numeroPiece; private String numeroPiece;
/** Date de l'écriture */ /** Date de l'écriture */
@NotNull @NotNull
@Column(name = "date_ecriture", nullable = false) @Column(name = "date_ecriture", nullable = false)
private LocalDate dateEcriture; private LocalDate dateEcriture;
/** Libellé de l'écriture */ /** Libellé de l'écriture */
@NotBlank @NotBlank
@Column(name = "libelle", nullable = false, length = 500) @Column(name = "libelle", nullable = false, length = 500)
private String libelle; private String libelle;
/** Référence externe */ /** Référence externe */
@Column(name = "reference", length = 100) @Column(name = "reference", length = 100)
private String reference; private String reference;
/** Lettrage (pour rapprochement) */ /** Lettrage (pour rapprochement) */
@Column(name = "lettrage", length = 20) @Column(name = "lettrage", length = 20)
private String lettrage; private String lettrage;
/** Pointage (pour rapprochement bancaire) */ /** Pointage (pour rapprochement bancaire) */
@Builder.Default @Builder.Default
@Column(name = "pointe", nullable = false) @Column(name = "pointe", nullable = false)
private Boolean pointe = false; private Boolean pointe = false;
/** Montant total débit (somme des lignes) */ /** Montant total débit (somme des lignes) */
@Builder.Default @Builder.Default
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "montant_debit", precision = 14, scale = 2) @Column(name = "montant_debit", precision = 14, scale = 2)
private BigDecimal montantDebit = BigDecimal.ZERO; private BigDecimal montantDebit = BigDecimal.ZERO;
/** Montant total crédit (somme des lignes) */ /** Montant total crédit (somme des lignes) */
@Builder.Default @Builder.Default
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "montant_credit", precision = 14, scale = 2) @Column(name = "montant_credit", precision = 14, scale = 2)
private BigDecimal montantCredit = BigDecimal.ZERO; private BigDecimal montantCredit = BigDecimal.ZERO;
/** Commentaires */ /** Commentaires */
@Column(name = "commentaire", length = 1000) @Column(name = "commentaire", length = 1000)
private String commentaire; private String commentaire;
// Relations // Relations
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "journal_id", nullable = false) @JoinColumn(name = "journal_id", nullable = false)
private JournalComptable journal; private JournalComptable journal;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id") @JoinColumn(name = "paiement_id")
private Paiement paiement; private Paiement paiement;
/** Lignes d'écriture */ /** Lignes d'écriture */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "ecriture", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OneToMany(mappedBy = "ecriture", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<LigneEcriture> lignes = new ArrayList<>(); private List<LigneEcriture> lignes = new ArrayList<>();
/** Méthode métier pour vérifier l'équilibre (Débit = Crédit) */ /** Méthode métier pour vérifier l'équilibre (Débit = Crédit) */
public boolean isEquilibree() { public boolean isEquilibree() {
if (montantDebit == null || montantCredit == null) { if (montantDebit == null || montantCredit == null) {
return false; return false;
} }
return montantDebit.compareTo(montantCredit) == 0; return montantDebit.compareTo(montantCredit) == 0;
} }
/** Méthode métier pour calculer les totaux à partir des lignes */ /** Méthode métier pour calculer les totaux à partir des lignes */
public void calculerTotaux() { public void calculerTotaux() {
if (lignes == null || lignes.isEmpty()) { if (lignes == null || lignes.isEmpty()) {
montantDebit = BigDecimal.ZERO; montantDebit = BigDecimal.ZERO;
montantCredit = BigDecimal.ZERO; montantCredit = BigDecimal.ZERO;
return; return;
} }
montantDebit = montantDebit =
lignes.stream() lignes.stream()
.map(LigneEcriture::getMontantDebit) .map(LigneEcriture::getMontantDebit)
.filter(amount -> amount != null) .filter(amount -> amount != null)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
montantCredit = montantCredit =
lignes.stream() lignes.stream()
.map(LigneEcriture::getMontantCredit) .map(LigneEcriture::getMontantCredit)
.filter(amount -> amount != null) .filter(amount -> amount != null)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
} }
/** Méthode métier pour générer un numéro de pièce unique */ /** Méthode métier pour générer un numéro de pièce unique */
public static String genererNumeroPiece(String prefixe, LocalDate date) { public static String genererNumeroPiece(String prefixe, LocalDate date) {
return String.format( return String.format(
"%s-%04d%02d%02d-%012d", "%s-%04d%02d%02d-%012d",
prefixe, date.getYear(), date.getMonthValue(), date.getDayOfMonth(), prefixe, date.getYear(), date.getMonthValue(), date.getDayOfMonth(),
System.currentTimeMillis() % 1000000000000L); System.currentTimeMillis() % 1000000000000L);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (numeroPiece == null || numeroPiece.isEmpty()) { if (numeroPiece == null || numeroPiece.isEmpty()) {
numeroPiece = genererNumeroPiece("ECR", dateEcriture != null ? dateEcriture : LocalDate.now()); numeroPiece = genererNumeroPiece("ECR", dateEcriture != null ? dateEcriture : LocalDate.now());
} }
if (dateEcriture == null) { if (dateEcriture == null) {
dateEcriture = LocalDate.now(); dateEcriture = LocalDate.now();
} }
if (montantDebit == null) { if (montantDebit == null) {
montantDebit = BigDecimal.ZERO; montantDebit = BigDecimal.ZERO;
} }
if (montantCredit == null) { if (montantCredit == null) {
montantCredit = BigDecimal.ZERO; montantCredit = BigDecimal.ZERO;
} }
if (pointe == null) { if (pointe == null) {
pointe = false; pointe = false;
} }
// Calculer les totaux si les lignes sont déjà présentes // Calculer les totaux si les lignes sont déjà présentes
if (lignes != null && !lignes.isEmpty()) { if (lignes != null && !lignes.isEmpty()) {
calculerTotaux(); calculerTotaux();
} }
} }
/** Callback JPA avant la mise à jour */ /** Callback JPA avant la mise à jour */
@PreUpdate @PreUpdate
protected void onUpdate() { protected void onUpdate() {
calculerTotaux(); calculerTotaux();
} }
} }

View File

@@ -1,259 +1,259 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import lombok.*; import lombok.*;
/** /**
* Entité Événement pour la gestion des événements de l'union * Entité Événement pour la gestion des événements de l'union
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 2.0 * @version 2.0
* @since 2025-01-16 * @since 2025-01-16
*/ */
@Entity @Entity
@Table(name = "evenements", indexes = { @Table(name = "evenements", indexes = {
@Index(name = "idx_evenement_date_debut", columnList = "date_debut"), @Index(name = "idx_evenement_date_debut", columnList = "date_debut"),
@Index(name = "idx_evenement_statut", columnList = "statut"), @Index(name = "idx_evenement_statut", columnList = "statut"),
@Index(name = "idx_evenement_type", columnList = "type_evenement"), @Index(name = "idx_evenement_type", columnList = "type_evenement"),
@Index(name = "idx_evenement_organisation", columnList = "organisation_id") @Index(name = "idx_evenement_organisation", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Evenement extends BaseEntity { public class Evenement extends BaseEntity {
@NotBlank @NotBlank
@Size(min = 3, max = 200) @Size(min = 3, max = 200)
@Column(name = "titre", nullable = false, length = 200) @Column(name = "titre", nullable = false, length = 200)
private String titre; private String titre;
@Size(max = 2000) @Size(max = 2000)
@Column(name = "description", length = 2000) @Column(name = "description", length = 2000)
private String description; private String description;
@NotNull @NotNull
@Column(name = "date_debut", nullable = false) @Column(name = "date_debut", nullable = false)
private LocalDateTime dateDebut; private LocalDateTime dateDebut;
@Column(name = "date_fin") @Column(name = "date_fin")
private LocalDateTime dateFin; private LocalDateTime dateFin;
@Size(max = 500) @Size(max = 500)
@Column(name = "lieu", length = 500) @Column(name = "lieu", length = 500)
private String lieu; private String lieu;
@Size(max = 1000) @Size(max = 1000)
@Column(name = "adresse", length = 1000) @Column(name = "adresse", length = 1000)
private String adresse; private String adresse;
@Column(name = "type_evenement", length = 50) @Column(name = "type_evenement", length = 50)
private String typeEvenement; private String typeEvenement;
@Builder.Default @Builder.Default
@Column(name = "statut", nullable = false, length = 30) @Column(name = "statut", nullable = false, length = 30)
private String statut = "PLANIFIE"; private String statut = "PLANIFIE";
@Min(0) @Min(0)
@Column(name = "capacite_max") @Column(name = "capacite_max")
private Integer capaciteMax; private Integer capaciteMax;
@DecimalMin("0.00") @DecimalMin("0.00")
@Digits(integer = 8, fraction = 2) @Digits(integer = 8, fraction = 2)
@Column(name = "prix", precision = 10, scale = 2) @Column(name = "prix", precision = 10, scale = 2)
private BigDecimal prix; private BigDecimal prix;
@Builder.Default @Builder.Default
@Column(name = "inscription_requise", nullable = false) @Column(name = "inscription_requise", nullable = false)
private Boolean inscriptionRequise = false; private Boolean inscriptionRequise = false;
@Column(name = "date_limite_inscription") @Column(name = "date_limite_inscription")
private LocalDateTime dateLimiteInscription; private LocalDateTime dateLimiteInscription;
@Size(max = 1000) @Size(max = 1000)
@Column(name = "instructions_particulieres", length = 1000) @Column(name = "instructions_particulieres", length = 1000)
private String instructionsParticulieres; private String instructionsParticulieres;
@Size(max = 500) @Size(max = 500)
@Column(name = "contact_organisateur", length = 500) @Column(name = "contact_organisateur", length = 500)
private String contactOrganisateur; private String contactOrganisateur;
@Size(max = 2000) @Size(max = 2000)
@Column(name = "materiel_requis", length = 2000) @Column(name = "materiel_requis", length = 2000)
private String materielRequis; private String materielRequis;
@Builder.Default @Builder.Default
@Column(name = "visible_public", nullable = false) @Column(name = "visible_public", nullable = false)
private Boolean visiblePublic = true; private Boolean visiblePublic = true;
// Relations // Relations
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisateur_id") @JoinColumn(name = "organisateur_id")
private Membre organisateur; private Membre organisateur;
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "evenement", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OneToMany(mappedBy = "evenement", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<InscriptionEvenement> inscriptions = new ArrayList<>(); private List<InscriptionEvenement> inscriptions = new ArrayList<>();
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "evenement", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "evenement", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<Adresse> adresses = new ArrayList<>(); private List<Adresse> adresses = new ArrayList<>();
/** Types d'événements */ /** Types d'événements */
public enum TypeEvenement { public enum TypeEvenement {
ASSEMBLEE_GENERALE("Assemblée Générale"), ASSEMBLEE_GENERALE("Assemblée Générale"),
REUNION("Réunion"), REUNION("Réunion"),
FORMATION("Formation"), FORMATION("Formation"),
CONFERENCE("Conférence"), CONFERENCE("Conférence"),
ATELIER("Atelier"), ATELIER("Atelier"),
SEMINAIRE("Séminaire"), SEMINAIRE("Séminaire"),
EVENEMENT_SOCIAL("Événement Social"), EVENEMENT_SOCIAL("Événement Social"),
MANIFESTATION("Manifestation"), MANIFESTATION("Manifestation"),
CELEBRATION("Célébration"), CELEBRATION("Célébration"),
AUTRE("Autre"); AUTRE("Autre");
private final String libelle; private final String libelle;
TypeEvenement(String libelle) { TypeEvenement(String libelle) {
this.libelle = libelle; this.libelle = libelle;
} }
public String getLibelle() { public String getLibelle() {
return libelle; return libelle;
} }
} }
/** Statuts d'événement */ /** Statuts d'événement */
public enum StatutEvenement { public enum StatutEvenement {
PLANIFIE("Planifié"), PLANIFIE("Planifié"),
CONFIRME("Confirmé"), CONFIRME("Confirmé"),
EN_COURS("En cours"), EN_COURS("En cours"),
TERMINE("Terminé"), TERMINE("Terminé"),
ANNULE("Annulé"), ANNULE("Annulé"),
REPORTE("Reporté"); REPORTE("Reporté");
private final String libelle; private final String libelle;
StatutEvenement(String libelle) { StatutEvenement(String libelle) {
this.libelle = libelle; this.libelle = libelle;
} }
public String getLibelle() { public String getLibelle() {
return libelle; return libelle;
} }
} }
// Méthodes métier // Méthodes métier
/** Vérifie si l'événement est ouvert aux inscriptions */ /** Vérifie si l'événement est ouvert aux inscriptions */
@JsonIgnore @JsonIgnore
public boolean isOuvertAuxInscriptions() { public boolean isOuvertAuxInscriptions() {
if (!inscriptionRequise || !getActif()) { if (!inscriptionRequise || !getActif()) {
return false; return false;
} }
LocalDateTime maintenant = LocalDateTime.now(); LocalDateTime maintenant = LocalDateTime.now();
// Vérifier si la date limite d'inscription n'est pas dépassée // Vérifier si la date limite d'inscription n'est pas dépassée
if (dateLimiteInscription != null && maintenant.isAfter(dateLimiteInscription)) { if (dateLimiteInscription != null && maintenant.isAfter(dateLimiteInscription)) {
return false; return false;
} }
// Vérifier si l'événement n'a pas déjà commencé // Vérifier si l'événement n'a pas déjà commencé
if (maintenant.isAfter(dateDebut)) { if (maintenant.isAfter(dateDebut)) {
return false; return false;
} }
// Vérifier la capacité // Vérifier la capacité
if (capaciteMax != null && getNombreInscrits() >= capaciteMax) { if (capaciteMax != null && getNombreInscrits() >= capaciteMax) {
return false; return false;
} }
return "PLANIFIE".equals(statut) || "CONFIRME".equals(statut); return "PLANIFIE".equals(statut) || "CONFIRME".equals(statut);
} }
/** Obtient le nombre d'inscrits à l'événement */ /** Obtient le nombre d'inscrits à l'événement */
@JsonIgnore @JsonIgnore
public int getNombreInscrits() { public int getNombreInscrits() {
return inscriptions != null return inscriptions != null
? (int) inscriptions.stream() ? (int) inscriptions.stream()
.filter( .filter(
inscription -> InscriptionEvenement.StatutInscription.CONFIRMEE.name().equals(inscription.getStatut())) inscription -> InscriptionEvenement.StatutInscription.CONFIRMEE.name().equals(inscription.getStatut()))
.count() .count()
: 0; : 0;
} }
/** Vérifie si l'événement est complet */ /** Vérifie si l'événement est complet */
@JsonIgnore @JsonIgnore
public boolean isComplet() { public boolean isComplet() {
return capaciteMax != null && getNombreInscrits() >= capaciteMax; return capaciteMax != null && getNombreInscrits() >= capaciteMax;
} }
/** Vérifie si l'événement est en cours */ /** Vérifie si l'événement est en cours */
public boolean isEnCours() { public boolean isEnCours() {
LocalDateTime maintenant = LocalDateTime.now(); LocalDateTime maintenant = LocalDateTime.now();
return maintenant.isAfter(dateDebut) && (dateFin == null || maintenant.isBefore(dateFin)); return maintenant.isAfter(dateDebut) && (dateFin == null || maintenant.isBefore(dateFin));
} }
/** Vérifie si l'événement est terminé */ /** Vérifie si l'événement est terminé */
public boolean isTermine() { public boolean isTermine() {
if ("TERMINE".equals(statut)) { if ("TERMINE".equals(statut)) {
return true; return true;
} }
LocalDateTime maintenant = LocalDateTime.now(); LocalDateTime maintenant = LocalDateTime.now();
return dateFin != null && maintenant.isAfter(dateFin); return dateFin != null && maintenant.isAfter(dateFin);
} }
/** Calcule la durée de l'événement en heures */ /** Calcule la durée de l'événement en heures */
public Long getDureeEnHeures() { public Long getDureeEnHeures() {
if (dateFin == null) { if (dateFin == null) {
return null; return null;
} }
return java.time.Duration.between(dateDebut, dateFin).toHours(); return java.time.Duration.between(dateDebut, dateFin).toHours();
} }
/** Obtient le nombre de places restantes */ /** Obtient le nombre de places restantes */
@JsonIgnore @JsonIgnore
public Integer getPlacesRestantes() { public Integer getPlacesRestantes() {
if (capaciteMax == null) { if (capaciteMax == null) {
return null; // Capacité illimitée return null; // Capacité illimitée
} }
return Math.max(0, capaciteMax - getNombreInscrits()); return Math.max(0, capaciteMax - getNombreInscrits());
} }
/** Vérifie si un membre est inscrit à l'événement */ /** Vérifie si un membre est inscrit à l'événement */
public boolean isMemberInscrit(UUID membreId) { public boolean isMemberInscrit(UUID membreId) {
return inscriptions != null return inscriptions != null
&& inscriptions.stream() && inscriptions.stream()
.anyMatch( .anyMatch(
inscription -> inscription.getMembre().getId().equals(membreId) inscription -> inscription.getMembre().getId().equals(membreId)
&& InscriptionEvenement.StatutInscription.CONFIRMEE.name().equals(inscription.getStatut())); && InscriptionEvenement.StatutInscription.CONFIRMEE.name().equals(inscription.getStatut()));
} }
/** Obtient le taux de remplissage en pourcentage */ /** Obtient le taux de remplissage en pourcentage */
@JsonIgnore @JsonIgnore
public Double getTauxRemplissage() { public Double getTauxRemplissage() {
if (capaciteMax == null || capaciteMax == 0) { if (capaciteMax == null || capaciteMax == 0) {
return null; return null;
} }
return (double) getNombreInscrits() / capaciteMax * 100; return (double) getNombreInscrits() / capaciteMax * 100;
} }
} }

View File

@@ -1,79 +1,79 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
/** /**
* Entité Favori pour la gestion des favoris utilisateur * Entité Favori pour la gestion des favoris utilisateur
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
*/ */
@Entity @Entity
@Table( @Table(
name = "favoris", name = "favoris",
indexes = { indexes = {
@Index(name = "idx_favori_utilisateur", columnList = "utilisateur_id"), @Index(name = "idx_favori_utilisateur", columnList = "utilisateur_id"),
@Index(name = "idx_favori_type", columnList = "type_favori"), @Index(name = "idx_favori_type", columnList = "type_favori"),
@Index(name = "idx_favori_categorie", columnList = "categorie") @Index(name = "idx_favori_categorie", columnList = "categorie")
} }
) )
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Favori extends BaseEntity { public class Favori extends BaseEntity {
@NotNull @NotNull
@Column(name = "utilisateur_id", nullable = false) @Column(name = "utilisateur_id", nullable = false)
private UUID utilisateurId; private UUID utilisateurId;
@NotBlank @NotBlank
@Column(name = "type_favori", nullable = false, length = 50) @Column(name = "type_favori", nullable = false, length = 50)
private String typeFavori; // PAGE, DOCUMENT, CONTACT, RACCOURCI private String typeFavori; // PAGE, DOCUMENT, CONTACT, RACCOURCI
@NotBlank @NotBlank
@Column(name = "titre", nullable = false, length = 255) @Column(name = "titre", nullable = false, length = 255)
private String titre; private String titre;
@Column(name = "description", length = 1000) @Column(name = "description", length = 1000)
private String description; private String description;
@Column(name = "url", length = 1000) @Column(name = "url", length = 1000)
private String url; private String url;
@Column(name = "icon", length = 100) @Column(name = "icon", length = 100)
private String icon; private String icon;
@Column(name = "couleur", length = 50) @Column(name = "couleur", length = 50)
private String couleur; private String couleur;
@Column(name = "categorie", length = 100) @Column(name = "categorie", length = 100)
private String categorie; private String categorie;
@Column(name = "ordre") @Column(name = "ordre")
@Builder.Default @Builder.Default
private Integer ordre = 0; private Integer ordre = 0;
@Column(name = "nb_visites") @Column(name = "nb_visites")
@Builder.Default @Builder.Default
private Integer nbVisites = 0; private Integer nbVisites = 0;
@Column(name = "derniere_visite") @Column(name = "derniere_visite")
private LocalDateTime derniereVisite; private LocalDateTime derniereVisite;
@Column(name = "est_plus_utilise") @Column(name = "est_plus_utilise")
@Builder.Default @Builder.Default
private Boolean estPlusUtilise = false; private Boolean estPlusUtilise = false;
} }

View File

@@ -1,117 +1,117 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.*; import lombok.*;
/** /**
* Entité FeedbackEvenement représentant l'évaluation d'un membre sur un événement * Entité FeedbackEvenement représentant l'évaluation d'un membre sur un événement
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-16 * @since 2026-03-16
*/ */
@Entity @Entity
@Table( @Table(
name = "feedbacks_evenement", name = "feedbacks_evenement",
indexes = { indexes = {
@Index(name = "idx_feedback_membre", columnList = "membre_id"), @Index(name = "idx_feedback_membre", columnList = "membre_id"),
@Index(name = "idx_feedback_evenement", columnList = "evenement_id"), @Index(name = "idx_feedback_evenement", columnList = "evenement_id"),
@Index(name = "idx_feedback_date", columnList = "date_feedback") @Index(name = "idx_feedback_date", columnList = "date_feedback")
}, },
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint( @UniqueConstraint(
name = "uk_feedback_membre_evenement", name = "uk_feedback_membre_evenement",
columnNames = {"membre_id", "evenement_id"}) columnNames = {"membre_id", "evenement_id"})
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class FeedbackEvenement extends BaseEntity { public class FeedbackEvenement extends BaseEntity {
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false) @JoinColumn(name = "membre_id", nullable = false)
private Membre membre; private Membre membre;
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "evenement_id", nullable = false) @JoinColumn(name = "evenement_id", nullable = false)
private Evenement evenement; private Evenement evenement;
@NotNull @NotNull
@Min(1) @Min(1)
@Max(5) @Max(5)
@Column(name = "note", nullable = false) @Column(name = "note", nullable = false)
private Integer note; private Integer note;
@Column(name = "commentaire", length = 1000) @Column(name = "commentaire", length = 1000)
private String commentaire; private String commentaire;
@Builder.Default @Builder.Default
@Column(name = "date_feedback", nullable = false) @Column(name = "date_feedback", nullable = false)
private LocalDateTime dateFeedback = LocalDateTime.now(); private LocalDateTime dateFeedback = LocalDateTime.now();
@Column(name = "moderation_statut", length = 20) @Column(name = "moderation_statut", length = 20)
@Builder.Default @Builder.Default
private String moderationStatut = ModerationStatut.PUBLIE.name(); private String moderationStatut = ModerationStatut.PUBLIE.name();
@Column(name = "raison_moderation", length = 500) @Column(name = "raison_moderation", length = 500)
private String raisonModeration; private String raisonModeration;
/** Énumération des statuts de modération */ /** Énumération des statuts de modération */
public enum ModerationStatut { public enum ModerationStatut {
PUBLIE, // Visible publiquement PUBLIE, // Visible publiquement
EN_ATTENTE, // En attente de modération EN_ATTENTE, // En attente de modération
REJETE // Rejeté par modération REJETE // Rejeté par modération
} }
// Méthodes utilitaires // Méthodes utilitaires
/** Vérifie si le feedback est publié */ /** Vérifie si le feedback est publié */
public boolean isPublie() { public boolean isPublie() {
return ModerationStatut.PUBLIE.name().equals(this.moderationStatut); return ModerationStatut.PUBLIE.name().equals(this.moderationStatut);
} }
/** Marque le feedback comme en attente de modération */ /** Marque le feedback comme en attente de modération */
public void mettreEnAttente(String raison) { public void mettreEnAttente(String raison) {
this.moderationStatut = ModerationStatut.EN_ATTENTE.name(); this.moderationStatut = ModerationStatut.EN_ATTENTE.name();
this.raisonModeration = raison; this.raisonModeration = raison;
setDateModification(LocalDateTime.now()); setDateModification(LocalDateTime.now());
} }
/** Publie le feedback */ /** Publie le feedback */
public void publier() { public void publier() {
this.moderationStatut = ModerationStatut.PUBLIE.name(); this.moderationStatut = ModerationStatut.PUBLIE.name();
this.raisonModeration = null; this.raisonModeration = null;
setDateModification(LocalDateTime.now()); setDateModification(LocalDateTime.now());
} }
/** Rejette le feedback */ /** Rejette le feedback */
public void rejeter(String raison) { public void rejeter(String raison) {
this.moderationStatut = ModerationStatut.REJETE.name(); this.moderationStatut = ModerationStatut.REJETE.name();
this.raisonModeration = raison; this.raisonModeration = raison;
setDateModification(LocalDateTime.now()); setDateModification(LocalDateTime.now());
} }
@PreUpdate @PreUpdate
public void preUpdate() { public void preUpdate() {
super.onUpdate(); super.onUpdate();
} }
@Override @Override
public String toString() { public String toString() {
return String.format( return String.format(
"FeedbackEvenement{id=%s, membre=%s, evenement=%s, note=%d, dateFeedback=%s}", "FeedbackEvenement{id=%s, membre=%s, evenement=%s, note=%d, dateFeedback=%s}",
getId(), getId(),
membre != null ? membre.getEmail() : "null", membre != null ? membre.getEmail() : "null",
evenement != null ? evenement.getTitre() : "null", evenement != null ? evenement.getTitre() : "null",
note, note,
dateFeedback); dateFeedback);
} }
} }

View File

@@ -0,0 +1,75 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Session de formation LBC/FT (lutte contre le blanchiment de capitaux et le financement du
* terrorisme).
*
* <p>Obligation annuelle posée par l'<strong>Instruction BCEAO 001-03-2025 du 18 mars 2025</strong>
* pour le compliance officer + les dirigeants + les membres exposés (trésorier, secrétaire,
* commissaires aux comptes).
*
* @since 2026-04-25 (P1-NEW-12)
*/
@Entity
@Table(name = "formations_lbcft", indexes = {
@Index(name = "idx_formation_org_annee", columnList = "organisation_id,annee_reference"),
@Index(name = "idx_formation_date", columnList = "date_session")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class FormationLbcFt extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotBlank
@Column(name = "titre", nullable = false, length = 255)
private String titre;
@NotBlank
@Column(name = "type_formation", nullable = false, length = 30)
@Builder.Default
private String typeFormation = "STANDARD"; // STANDARD, AVANCE, COMPLIANCE_OFFICER, DIRIGEANT
@Column(name = "contenu", columnDefinition = "TEXT")
private String contenu;
@Column(name = "intervenant", length = 255)
private String intervenant;
@Column(name = "duree_heures", precision = 4, scale = 1, nullable = false)
@Builder.Default
private BigDecimal dureeHeures = new BigDecimal("4.0");
@NotNull
@Column(name = "date_session", nullable = false)
private LocalDateTime dateSession;
@Column(name = "lieu", length = 255)
private String lieu;
@NotNull
@Column(name = "annee_reference", nullable = false)
private Integer anneeReference;
@NotBlank
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private String statut = "PLANIFIEE"; // PLANIFIEE, EN_COURS, TERMINEE, ANNULEE
}

View File

@@ -1,120 +1,124 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres; import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres;
import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule; import dev.lions.unionflow.server.api.enums.abonnement.TypeFormule;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import lombok.*; import lombok.*;
/** /**
* Catalogue des forfaits SaaS UnionFlow. * Catalogue des forfaits SaaS UnionFlow.
* *
* <p>Starter (≤50) → Standard (≤200) → Premium (≤500) → Crystal (illimité) * <p>Starter (≤50) → Standard (≤200) → Premium (≤500) → Crystal (illimité)
* Fourchette tarifaire : 5 000 à 10 000 XOF/mois. Stockage max : 1 Go. * Fourchette tarifaire : 5 000 à 10 000 XOF/mois. Stockage max : 1 Go.
* *
* <p>Table : {@code formules_abonnement} * <p>Table : {@code formules_abonnement}
*/ */
@Entity @Entity
@Table( @Table(
name = "formules_abonnement", name = "formules_abonnement",
indexes = { indexes = {
@Index(name = "idx_formule_code_plage", columnList = "code, plage", unique = true), @Index(name = "idx_formule_code_plage", columnList = "code, plage", unique = true),
@Index(name = "idx_formule_code", columnList = "code"), @Index(name = "idx_formule_code", columnList = "code"),
@Index(name = "idx_formule_plage", columnList = "plage"), @Index(name = "idx_formule_plage", columnList = "plage"),
@Index(name = "idx_formule_actif", columnList = "actif") @Index(name = "idx_formule_actif", columnList = "actif")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class FormuleAbonnement extends BaseEntity { public class FormuleAbonnement extends BaseEntity {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@NotNull @NotNull
@Column(name = "code", nullable = false, length = 20) @Column(name = "code", nullable = false, length = 20)
private TypeFormule code; private TypeFormule code;
/** /**
* Plage de taille d'organisation à laquelle cette formule s'applique. * Plage de taille d'organisation à laquelle cette formule s'applique.
* Combinée avec le code de formule, forme une clé unique dans le catalogue. * Combinée avec le code de formule, forme une clé unique dans le catalogue.
*/ */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@NotNull @NotNull
@Column(name = "plage", nullable = false, length = 20) @Column(name = "plage", nullable = false, length = 20)
private PlageMembres plage; private PlageMembres plage;
@NotBlank @NotBlank
@Column(name = "libelle", nullable = false, length = 100) @Column(name = "libelle", nullable = false, length = 100)
private String libelle; private String libelle;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
/** Nombre maximum de membres. NULL = illimité (Crystal) */ /** Nombre maximum de membres. NULL = illimité (Crystal) */
@Column(name = "max_membres") @Column(name = "max_membres")
private Integer maxMembres; private Integer maxMembres;
/** Stockage maximum en Mo — 1 024 Mo (1 Go) par défaut */ /** Stockage maximum en Mo — 1 024 Mo (1 Go) par défaut */
@Builder.Default @Builder.Default
@Column(name = "max_stockage_mo", nullable = false) @Column(name = "max_stockage_mo", nullable = false)
private Integer maxStockageMo = 1024; private Integer maxStockageMo = 1024;
@NotNull @NotNull
@DecimalMin("0.00") @DecimalMin("0.00")
@Digits(integer = 8, fraction = 2) @Digits(integer = 8, fraction = 2)
@Column(name = "prix_mensuel", nullable = false, precision = 10, scale = 2) @Column(name = "prix_mensuel", nullable = false, precision = 10, scale = 2)
private BigDecimal prixMensuel; private BigDecimal prixMensuel;
@NotNull @NotNull
@DecimalMin("0.00") @DecimalMin("0.00")
@Digits(integer = 8, fraction = 2) @Digits(integer = 8, fraction = 2)
@Column(name = "prix_annuel", nullable = false, precision = 10, scale = 2) @Column(name = "prix_annuel", nullable = false, precision = 10, scale = 2)
private BigDecimal prixAnnuel; private BigDecimal prixAnnuel;
@Builder.Default @Builder.Default
@Column(name = "ordre_affichage", nullable = false) @Column(name = "ordre_affichage", nullable = false)
private Integer ordreAffichage = 0; private Integer ordreAffichage = 0;
// ── Champs Option C (ajoutés en V19) ────────────────────────────────────── // ── Champs Option C (ajoutés en V19) ──────────────────────────────────────
/** Nom commercial du plan (MICRO, DECOUVERTE, ESSENTIEL, AVANCE, PROFESSIONNEL, ENTERPRISE) */ /** Nom commercial du plan (MICRO, DECOUVERTE, ESSENTIEL, AVANCE, PROFESSIONNEL, ENTERPRISE) */
@Column(name = "plan_commercial", length = 30) @Column(name = "plan_commercial", length = 30)
private String planCommercial; private String planCommercial;
/** Niveau de reporting disponible (BASIQUE, STANDARD, AVANCE) */ /** Niveau de reporting disponible (BASIQUE, STANDARD, AVANCE) */
@Column(name = "niveau_reporting", length = 20) @Column(name = "niveau_reporting", length = 20)
private String niveauReporting; private String niveauReporting;
/** Accès à l'API REST (false pour les plans de base) */ /** Accès à l'API REST (false pour les plans de base) */
@Builder.Default @Builder.Default
@Column(name = "api_access", nullable = false) @Column(name = "api_access", nullable = false)
private Boolean apiAccess = false; private Boolean apiAccess = false;
/** Accès au module de fédération multi-org (ENTERPRISE uniquement) */ /** Accès au module de fédération multi-org (ENTERPRISE uniquement) */
@Builder.Default @Builder.Default
@Column(name = "federation_access", nullable = false) @Column(name = "federation_access", nullable = false)
private Boolean federationAccess = false; private Boolean federationAccess = false;
/** Support prioritaire inclus */ /** Support prioritaire inclus */
@Builder.Default @Builder.Default
@Column(name = "support_prioritaire", nullable = false) @Column(name = "support_prioritaire", nullable = false)
private Boolean supportPrioritaire = false; private Boolean supportPrioritaire = false;
/** SLA garanti (ex: "99.0%", "99.9%") */ /** SLA garanti (ex: "99.0%", "99.9%") */
@Column(name = "sla_garanti", length = 10) @Column(name = "sla_garanti", length = 10)
private String slaGaranti; private String slaGaranti;
/** Nombre maximum d'administrateurs. NULL = illimité */ /** Nombre maximum d'administrateurs. NULL = illimité */
@Column(name = "max_admins") @Column(name = "max_admins")
private Integer maxAdmins; private Integer maxAdmins;
public boolean isIllimitee() { /** Code du provider de paiement par défaut (WAVE, ORANGE_MONEY, MTN_MOMO, PISPI). NULL = global. */
return maxMembres == null; @Column(name = "provider_defaut", length = 20)
} private String providerDefaut;
public boolean accepteNouveauMembre(int quotaActuel) { public boolean isIllimitee() {
return isIllimitee() || quotaActuel < maxMembres; return maxMembres == null;
} }
}
public boolean accepteNouveauMembre(int quotaActuel) {
return isIllimitee() || quotaActuel < maxMembres;
}
}

View File

@@ -1,143 +1,143 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.*; import lombok.*;
/** /**
* Entité InscriptionEvenement représentant l'inscription d'un membre à un * Entité InscriptionEvenement représentant l'inscription d'un membre à un
* événement * événement
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 2.0 * @version 2.0
* @since 2025-01-16 * @since 2025-01-16
*/ */
@Entity @Entity
@Table(name = "inscriptions_evenement", indexes = { @Table(name = "inscriptions_evenement", indexes = {
@Index(name = "idx_inscription_membre", columnList = "membre_id"), @Index(name = "idx_inscription_membre", columnList = "membre_id"),
@Index(name = "idx_inscription_evenement", columnList = "evenement_id"), @Index(name = "idx_inscription_evenement", columnList = "evenement_id"),
@Index(name = "idx_inscription_date", columnList = "date_inscription") @Index(name = "idx_inscription_date", columnList = "date_inscription")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class InscriptionEvenement extends BaseEntity { public class InscriptionEvenement extends BaseEntity {
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false) @JoinColumn(name = "membre_id", nullable = false)
private Membre membre; private Membre membre;
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "evenement_id", nullable = false) @JoinColumn(name = "evenement_id", nullable = false)
private Evenement evenement; private Evenement evenement;
@Builder.Default @Builder.Default
@Column(name = "date_inscription", nullable = false) @Column(name = "date_inscription", nullable = false)
private LocalDateTime dateInscription = LocalDateTime.now(); private LocalDateTime dateInscription = LocalDateTime.now();
@Column(name = "statut", length = 20) @Column(name = "statut", length = 20)
@Builder.Default @Builder.Default
private String statut = StatutInscription.CONFIRMEE.name(); private String statut = StatutInscription.CONFIRMEE.name();
@Column(name = "commentaire", length = 500) @Column(name = "commentaire", length = 500)
private String commentaire; private String commentaire;
/** Énumération des statuts d'inscription (pour constantes) */ /** Énumération des statuts d'inscription (pour constantes) */
public enum StatutInscription { public enum StatutInscription {
CONFIRMEE, CONFIRMEE,
EN_ATTENTE, EN_ATTENTE,
ANNULEE, ANNULEE,
REFUSEE; REFUSEE;
} }
// Méthodes utilitaires // Méthodes utilitaires
/** /**
* Vérifie si l'inscription est confirmée * Vérifie si l'inscription est confirmée
* *
* @return true si l'inscription est confirmée * @return true si l'inscription est confirmée
*/ */
public boolean isConfirmee() { public boolean isConfirmee() {
return StatutInscription.CONFIRMEE.name().equals(this.statut); return StatutInscription.CONFIRMEE.name().equals(this.statut);
} }
/** /**
* Vérifie si l'inscription est en attente * Vérifie si l'inscription est en attente
* *
* @return true si l'inscription est en attente * @return true si l'inscription est en attente
*/ */
public boolean isEnAttente() { public boolean isEnAttente() {
return StatutInscription.EN_ATTENTE.name().equals(this.statut); return StatutInscription.EN_ATTENTE.name().equals(this.statut);
} }
/** /**
* Vérifie si l'inscription est annulée * Vérifie si l'inscription est annulée
* *
* @return true si l'inscription est annulée * @return true si l'inscription est annulée
*/ */
public boolean isAnnulee() { public boolean isAnnulee() {
return StatutInscription.ANNULEE.name().equals(this.statut); return StatutInscription.ANNULEE.name().equals(this.statut);
} }
/** Confirme l'inscription */ /** Confirme l'inscription */
public void confirmer() { public void confirmer() {
this.statut = StatutInscription.CONFIRMEE.name(); this.statut = StatutInscription.CONFIRMEE.name();
setDateModification(LocalDateTime.now()); setDateModification(LocalDateTime.now());
} }
/** /**
* Annule l'inscription * Annule l'inscription
* *
* @param commentaire le commentaire d'annulation * @param commentaire le commentaire d'annulation
*/ */
public void annuler(String commentaire) { public void annuler(String commentaire) {
this.statut = StatutInscription.ANNULEE.name(); this.statut = StatutInscription.ANNULEE.name();
this.commentaire = commentaire; this.commentaire = commentaire;
setDateModification(LocalDateTime.now()); setDateModification(LocalDateTime.now());
} }
/** /**
* Met l'inscription en attente * Met l'inscription en attente
* *
* @param commentaire le commentaire de mise en attente * @param commentaire le commentaire de mise en attente
*/ */
public void mettreEnAttente(String commentaire) { public void mettreEnAttente(String commentaire) {
this.statut = StatutInscription.EN_ATTENTE.name(); this.statut = StatutInscription.EN_ATTENTE.name();
this.commentaire = commentaire; this.commentaire = commentaire;
setDateModification(LocalDateTime.now()); setDateModification(LocalDateTime.now());
} }
/** /**
* Refuser l'inscription * Refuser l'inscription
* *
* @param commentaire le commentaire de refus * @param commentaire le commentaire de refus
*/ */
public void refuser(String commentaire) { public void refuser(String commentaire) {
this.statut = StatutInscription.REFUSEE.name(); this.statut = StatutInscription.REFUSEE.name();
this.commentaire = commentaire; this.commentaire = commentaire;
setDateModification(LocalDateTime.now()); setDateModification(LocalDateTime.now());
} }
// Callbacks JPA // Callbacks JPA
@PreUpdate @PreUpdate
public void preUpdate() { public void preUpdate() {
super.onUpdate(); super.onUpdate();
} }
@Override @Override
public String toString() { public String toString() {
return String.format( return String.format(
"InscriptionEvenement{id=%s, membre=%s, evenement=%s, statut=%s, dateInscription=%s}", "InscriptionEvenement{id=%s, membre=%s, evenement=%s, statut=%s, dateInscription=%s}",
getId(), getId(),
membre != null ? membre.getEmail() : "null", membre != null ? membre.getEmail() : "null",
evenement != null ? evenement.getTitre() : "null", evenement != null ? evenement.getTitre() : "null",
statut, statut,
dateInscription); dateInscription);
} }
} }

View File

@@ -1,122 +1,122 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.paiement.StatutIntentionPaiement; import dev.lions.unionflow.server.api.enums.paiement.StatutIntentionPaiement;
import dev.lions.unionflow.server.api.enums.paiement.TypeObjetIntentionPaiement; import dev.lions.unionflow.server.api.enums.paiement.TypeObjetIntentionPaiement;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.*; import lombok.*;
/** /**
* Hub centralisé pour tout paiement Wave initié depuis UnionFlow. * Hub centralisé pour tout paiement Wave initié depuis UnionFlow.
* *
* <p>Flux : * <p>Flux :
* <ol> * <ol>
* <li>UnionFlow crée une {@code IntentionPaiement} avec les objets cibles (cotisations, etc.)</li> * <li>UnionFlow crée une {@code IntentionPaiement} avec les objets cibles (cotisations, etc.)</li>
* <li>UnionFlow appelle l'API Wave → récupère {@code waveCheckoutSessionId}</li> * <li>UnionFlow appelle l'API Wave → récupère {@code waveCheckoutSessionId}</li>
* <li>Le membre confirme dans l'app Wave</li> * <li>Le membre confirme dans l'app Wave</li>
* <li>Wave envoie un webhook → UnionFlow réconcilie via {@code waveCheckoutSessionId}</li> * <li>Wave envoie un webhook → UnionFlow réconcilie via {@code waveCheckoutSessionId}</li>
* <li>UnionFlow valide automatiquement les objets listés dans {@code objetsCibles}</li> * <li>UnionFlow valide automatiquement les objets listés dans {@code objetsCibles}</li>
* </ol> * </ol>
* *
* <p>Table : {@code intentions_paiement} * <p>Table : {@code intentions_paiement}
*/ */
@Entity @Entity
@Table( @Table(
name = "intentions_paiement", name = "intentions_paiement",
indexes = { indexes = {
@Index(name = "idx_intention_utilisateur", columnList = "utilisateur_id"), @Index(name = "idx_intention_utilisateur", columnList = "utilisateur_id"),
@Index(name = "idx_intention_statut", columnList = "statut"), @Index(name = "idx_intention_statut", columnList = "statut"),
@Index(name = "idx_intention_wave_session", columnList = "wave_checkout_session_id", unique = true), @Index(name = "idx_intention_wave_session", columnList = "wave_checkout_session_id", unique = true),
@Index(name = "idx_intention_expiration", columnList = "date_expiration") @Index(name = "idx_intention_expiration", columnList = "date_expiration")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class IntentionPaiement extends BaseEntity { public class IntentionPaiement extends BaseEntity {
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "utilisateur_id", nullable = false) @JoinColumn(name = "utilisateur_id", nullable = false)
private Membre utilisateur; private Membre utilisateur;
/** NULL pour les abonnements UnionFlow SA (payés par l'organisation directement) */ /** NULL pour les abonnements UnionFlow SA (payés par l'organisation directement) */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
@NotNull @NotNull
@DecimalMin("0.01") @DecimalMin("0.01")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "montant_total", nullable = false, precision = 14, scale = 2) @Column(name = "montant_total", nullable = false, precision = 14, scale = 2)
private BigDecimal montantTotal; private BigDecimal montantTotal;
@NotBlank @NotBlank
@Pattern(regexp = "^[A-Z]{3}$") @Pattern(regexp = "^[A-Z]{3}$")
@Builder.Default @Builder.Default
@Column(name = "code_devise", nullable = false, length = 3) @Column(name = "code_devise", nullable = false, length = 3)
private String codeDevise = "XOF"; private String codeDevise = "XOF";
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@NotNull @NotNull
@Column(name = "type_objet", nullable = false, length = 30) @Column(name = "type_objet", nullable = false, length = 30)
private TypeObjetIntentionPaiement typeObjet; private TypeObjetIntentionPaiement typeObjet;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "statut", nullable = false, length = 20) @Column(name = "statut", nullable = false, length = 20)
private StatutIntentionPaiement statut = StatutIntentionPaiement.INITIEE; private StatutIntentionPaiement statut = StatutIntentionPaiement.INITIEE;
/** ID de session Wave — clé de réconciliation sur webhook */ /** ID de session Wave — clé de réconciliation sur webhook */
@Column(name = "wave_checkout_session_id", unique = true, length = 255) @Column(name = "wave_checkout_session_id", unique = true, length = 255)
private String waveCheckoutSessionId; private String waveCheckoutSessionId;
/** URL de paiement Wave à rediriger l'utilisateur */ /** URL de paiement Wave à rediriger l'utilisateur */
@Column(name = "wave_launch_url", length = 1000) @Column(name = "wave_launch_url", length = 1000)
private String waveLaunchUrl; private String waveLaunchUrl;
/** ID transaction Wave reçu via webhook */ /** ID transaction Wave reçu via webhook */
@Column(name = "wave_transaction_id", length = 100) @Column(name = "wave_transaction_id", length = 100)
private String waveTransactionId; private String waveTransactionId;
/** /**
* JSON : liste des objets couverts par ce paiement. * JSON : liste des objets couverts par ce paiement.
* Exemple : [{\"type\":\"COTISATION\",\"id\":\"uuid\",\"montant\":5000}, ...] * Exemple : [{\"type\":\"COTISATION\",\"id\":\"uuid\",\"montant\":5000}, ...]
*/ */
@Column(name = "objets_cibles", columnDefinition = "TEXT") @Column(name = "objets_cibles", columnDefinition = "TEXT")
private String objetsCibles; private String objetsCibles;
@Column(name = "date_expiration") @Column(name = "date_expiration")
private LocalDateTime dateExpiration; private LocalDateTime dateExpiration;
@Column(name = "date_completion") @Column(name = "date_completion")
private LocalDateTime dateCompletion; private LocalDateTime dateCompletion;
// ── Méthodes métier ──────────────────────────────────────────────────────── // ── Méthodes métier ────────────────────────────────────────────────────────
public boolean isActive() { public boolean isActive() {
return StatutIntentionPaiement.INITIEE.equals(statut) return StatutIntentionPaiement.INITIEE.equals(statut)
|| StatutIntentionPaiement.EN_COURS.equals(statut); || StatutIntentionPaiement.EN_COURS.equals(statut);
} }
public boolean isExpiree() { public boolean isExpiree() {
return dateExpiration != null && LocalDateTime.now().isAfter(dateExpiration); return dateExpiration != null && LocalDateTime.now().isAfter(dateExpiration);
} }
public boolean isCompletee() { public boolean isCompletee() {
return StatutIntentionPaiement.COMPLETEE.equals(statut); return StatutIntentionPaiement.COMPLETEE.equals(statut);
} }
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (statut == null) statut = StatutIntentionPaiement.INITIEE; if (statut == null) statut = StatutIntentionPaiement.INITIEE;
if (codeDevise == null) codeDevise = "XOF"; if (codeDevise == null) codeDevise = "XOF";
if (dateExpiration == null) { if (dateExpiration == null) {
dateExpiration = LocalDateTime.now().plusMinutes(30); dateExpiration = LocalDateTime.now().plusMinutes(30);
} }
} }
} }

View File

@@ -1,100 +1,108 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.comptabilite.TypeJournalComptable; import dev.lions.unionflow.server.api.enums.comptabilite.TypeJournalComptable;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité JournalComptable pour la gestion des journaux * Entité JournalComptable pour la gestion des journaux
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "journaux_comptables", name = "journaux_comptables",
indexes = { uniqueConstraints = {
@Index(name = "idx_journal_code", columnList = "code", unique = true), @UniqueConstraint(name = "uk_journaux_org_code", columnNames = {"organisation_id", "code"})
@Index(name = "idx_journal_type", columnList = "type_journal"), },
@Index(name = "idx_journal_periode", columnList = "date_debut, date_fin") indexes = {
}) @Index(name = "idx_journal_code", columnList = "code"),
@Data @Index(name = "idx_journal_type", columnList = "type_journal"),
@NoArgsConstructor @Index(name = "idx_journal_periode", columnList = "date_debut, date_fin")
@AllArgsConstructor })
@Builder @Data
@EqualsAndHashCode(callSuper = true) @NoArgsConstructor
public class JournalComptable extends BaseEntity { @AllArgsConstructor
@Builder
/** Code unique du journal */ @EqualsAndHashCode(callSuper = true)
@NotBlank public class JournalComptable extends BaseEntity {
@Column(name = "code", unique = true, nullable = false, length = 10)
private String code; /** Code du journal (unique par organisation). */
@NotBlank
/** Libellé du journal */ @Column(name = "code", nullable = false, length = 10)
@NotBlank private String code;
@Column(name = "libelle", nullable = false, length = 100)
private String libelle; /** Libellé du journal */
@NotBlank
/** Type de journal */ @Column(name = "libelle", nullable = false, length = 100)
@NotNull private String libelle;
@Enumerated(EnumType.STRING)
@Column(name = "type_journal", nullable = false, length = 30) /** Type de journal */
private TypeJournalComptable typeJournal; @NotNull
@Enumerated(EnumType.STRING)
/** Date de début de la période */ @Column(name = "type_journal", nullable = false, length = 30)
@Column(name = "date_debut") private TypeJournalComptable typeJournal;
private LocalDate dateDebut;
/** Date de début de la période */
/** Date de fin de la période */ @Column(name = "date_debut")
@Column(name = "date_fin") private LocalDate dateDebut;
private LocalDate dateFin;
/** Date de fin de la période */
/** Statut du journal (OUVERT, FERME, ARCHIVE) */ @Column(name = "date_fin")
@Builder.Default private LocalDate dateFin;
@Column(name = "statut", length = 20)
private String statut = "OUVERT"; /** Statut du journal (OUVERT, FERME, ARCHIVE) */
@Builder.Default
/** Description */ @Column(name = "statut", length = 20)
@Column(name = "description", length = 500) private String statut = "OUVERT";
private String description;
/** Description */
/** Écritures comptables associées */ @Column(name = "description", length = 500)
@JsonIgnore private String description;
@OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default /** Organisation propriétaire */
private List<EcritureComptable> ecritures = new ArrayList<>(); @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id")
/** Méthode métier pour vérifier si le journal est ouvert */ private Organisation organisation;
public boolean isOuvert() {
return "OUVERT".equals(statut); /** Écritures comptables associées */
} @JsonIgnore
@OneToMany(mappedBy = "journal", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
/** Méthode métier pour vérifier si une date est dans la période */ @Builder.Default
public boolean estDansPeriode(LocalDate date) { private List<EcritureComptable> ecritures = new ArrayList<>();
if (dateDebut == null || dateFin == null) {
return true; // Période illimitée /** Méthode métier pour vérifier si le journal est ouvert */
} public boolean isOuvert() {
return !date.isBefore(dateDebut) && !date.isAfter(dateFin); return "OUVERT".equals(statut);
} }
/** Callback JPA avant la persistance */ /** Méthode métier pour vérifier si une date est dans la période */
@PrePersist public boolean estDansPeriode(LocalDate date) {
protected void onCreate() { if (dateDebut == null || dateFin == null) {
super.onCreate(); return true; // Période illimitée
if (statut == null || statut.isEmpty()) { }
statut = "OUVERT"; return !date.isBefore(dateDebut) && !date.isAfter(dateFin);
} }
}
} /** Callback JPA avant la persistance */
@PrePersist
protected void onCreate() {
super.onCreate();
if (statut == null || statut.isEmpty()) {
statut = "OUVERT";
}
}
}

View File

@@ -0,0 +1,135 @@
package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.membre.NiveauRisqueKyc;
import dev.lions.unionflow.server.api.enums.membre.StatutKyc;
import dev.lions.unionflow.server.api.enums.membre.TypePieceIdentite;
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Dossier KYC/AML d'un membre — conformité GIABA/BCEAO LCB-FT.
*
* <p>Rétention 10 ans requise par le GIABA. La colonne {@code anneeReference}
* sert à l'archivage logique par année (partitionnement futur PostgreSQL).
*
* <p>Un seul dossier actif ({@code actif=true}) par membre à la fois.
* Les dossiers expirés ou archivés ont {@code actif=false}.
*/
@Entity
@Table(
name = "kyc_dossier",
indexes = {
@Index(name = "idx_kyc_membre_id", columnList = "membre_id"),
@Index(name = "idx_kyc_statut", columnList = "statut"),
@Index(name = "idx_kyc_niveau_risque", columnList = "niveau_risque"),
@Index(name = "idx_kyc_annee", columnList = "annee_reference")
}
)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class KycDossier extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_piece", nullable = false, length = 30)
private TypePieceIdentite typePiece;
@NotBlank
@Size(max = 50)
@Column(name = "numero_piece", nullable = false, length = 50)
private String numeroPiece;
@Column(name = "date_expiration_piece")
private LocalDate dateExpirationPiece;
@Size(max = 500)
@Column(name = "piece_identite_recto_file_id", length = 500)
private String pieceIdentiteRectoFileId;
@Size(max = 500)
@Column(name = "piece_identite_verso_file_id", length = 500)
private String pieceIdentiteVersoFileId;
@Size(max = 500)
@Column(name = "justif_domicile_file_id", length = 500)
private String justifDomicileFileId;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private StatutKyc statut = StatutKyc.NON_VERIFIE;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "niveau_risque", nullable = false, length = 20)
@Builder.Default
private NiveauRisqueKyc niveauRisque = NiveauRisqueKyc.FAIBLE;
@Min(0) @Max(100)
@Column(name = "score_risque", nullable = false)
@Builder.Default
private int scoreRisque = 0;
@Builder.Default
@Column(name = "est_pep", nullable = false)
private boolean estPep = false;
@Size(max = 5)
@Column(name = "nationalite", length = 5)
private String nationalite;
@Column(name = "date_verification")
private LocalDateTime dateVerification;
@Column(name = "validateur_id")
private UUID validateurId;
@Size(max = 1000)
@Column(name = "notes_validateur", length = 1000)
private String notesValidateur;
@Column(name = "annee_reference", nullable = false)
@Builder.Default
private int anneeReference = java.time.LocalDate.now().getYear();
/** Pays d'origine des fonds (ISO-3) — anti-blanchiment transferts internationaux. */
@Size(max = 3)
@Column(name = "pays_origine_fonds", length = 3)
private String paysOrigineFonds;
/** URL/chemin justificatif domicile étranger (facture EDF/British Gas/etc.) pour non-résidents. */
@Size(max = 500)
@Column(name = "justificatif_residence_etrangere", length = 500)
private String justificatifResidenceEtrangere;
/**
* Niveau de due diligence (Instr. BCEAO 001-03-2025) :
* <ul>
* <li>SIMPLIFIE — risque faible, opérations limitées</li>
* <li>STANDARD — défaut</li>
* <li>RENFORCE — non-résidents, PEP, FATF grey-list</li>
* </ul>
*/
@Size(max = 20)
@Column(name = "niveau_due_diligence", nullable = false, length = 20)
@Builder.Default
private String niveauDueDiligence = "STANDARD";
public boolean isPieceExpiree() {
return dateExpirationPiece != null && dateExpirationPiece.isBefore(LocalDate.now());
}
}

View File

@@ -1,100 +1,100 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité LigneEcriture pour les lignes d'une écriture comptable * Entité LigneEcriture pour les lignes d'une écriture comptable
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "lignes_ecriture", name = "lignes_ecriture",
indexes = { indexes = {
@Index(name = "idx_ligne_ecriture_ecriture", columnList = "ecriture_id"), @Index(name = "idx_ligne_ecriture_ecriture", columnList = "ecriture_id"),
@Index(name = "idx_ligne_ecriture_compte", columnList = "compte_comptable_id") @Index(name = "idx_ligne_ecriture_compte", columnList = "compte_comptable_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class LigneEcriture extends BaseEntity { public class LigneEcriture extends BaseEntity {
/** Numéro de ligne */ /** Numéro de ligne */
@NotNull @NotNull
@Min(value = 1, message = "Le numéro de ligne doit être positif") @Min(value = 1, message = "Le numéro de ligne doit être positif")
@Column(name = "numero_ligne", nullable = false) @Column(name = "numero_ligne", nullable = false)
private Integer numeroLigne; private Integer numeroLigne;
/** Montant débit */ /** Montant débit */
@DecimalMin(value = "0.0", message = "Le montant débit doit être positif ou nul") @DecimalMin(value = "0.0", message = "Le montant débit doit être positif ou nul")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "montant_debit", precision = 14, scale = 2) @Column(name = "montant_debit", precision = 14, scale = 2)
private BigDecimal montantDebit; private BigDecimal montantDebit;
/** Montant crédit */ /** Montant crédit */
@DecimalMin(value = "0.0", message = "Le montant crédit doit être positif ou nul") @DecimalMin(value = "0.0", message = "Le montant crédit doit être positif ou nul")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "montant_credit", precision = 14, scale = 2) @Column(name = "montant_credit", precision = 14, scale = 2)
private BigDecimal montantCredit; private BigDecimal montantCredit;
/** Libellé de la ligne */ /** Libellé de la ligne */
@Column(name = "libelle", length = 500) @Column(name = "libelle", length = 500)
private String libelle; private String libelle;
/** Référence */ /** Référence */
@Column(name = "reference", length = 100) @Column(name = "reference", length = 100)
private String reference; private String reference;
// Relations // Relations
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "ecriture_id", nullable = false) @JoinColumn(name = "ecriture_id", nullable = false)
private EcritureComptable ecriture; private EcritureComptable ecriture;
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "compte_comptable_id", nullable = false) @JoinColumn(name = "compte_comptable_id", nullable = false)
private CompteComptable compteComptable; private CompteComptable compteComptable;
/** Méthode métier pour vérifier que la ligne a soit un débit soit un crédit (pas les deux) */ /** Méthode métier pour vérifier que la ligne a soit un débit soit un crédit (pas les deux) */
public boolean isValide() { public boolean isValide() {
boolean aDebit = montantDebit != null && montantDebit.compareTo(BigDecimal.ZERO) > 0; boolean aDebit = montantDebit != null && montantDebit.compareTo(BigDecimal.ZERO) > 0;
boolean aCredit = montantCredit != null && montantCredit.compareTo(BigDecimal.ZERO) > 0; boolean aCredit = montantCredit != null && montantCredit.compareTo(BigDecimal.ZERO) > 0;
return aDebit != aCredit; // XOR : soit débit, soit crédit, pas les deux return aDebit != aCredit; // XOR : soit débit, soit crédit, pas les deux
} }
/** Méthode métier pour obtenir le montant (débit ou crédit) */ /** Méthode métier pour obtenir le montant (débit ou crédit) */
public BigDecimal getMontant() { public BigDecimal getMontant() {
if (montantDebit != null && montantDebit.compareTo(BigDecimal.ZERO) > 0) { if (montantDebit != null && montantDebit.compareTo(BigDecimal.ZERO) > 0) {
return montantDebit; return montantDebit;
} }
if (montantCredit != null && montantCredit.compareTo(BigDecimal.ZERO) > 0) { if (montantCredit != null && montantCredit.compareTo(BigDecimal.ZERO) > 0) {
return montantCredit; return montantCredit;
} }
return BigDecimal.ZERO; return BigDecimal.ZERO;
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (montantDebit == null) { if (montantDebit == null) {
montantDebit = BigDecimal.ZERO; montantDebit = BigDecimal.ZERO;
} }
if (montantCredit == null) { if (montantCredit == null) {
montantCredit = BigDecimal.ZERO; montantCredit = BigDecimal.ZERO;
} }
} }
} }

View File

@@ -1,169 +1,215 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import lombok.*; import lombok.*;
/** /**
* Identité globale unique d'un utilisateur UnionFlow. * Identité globale unique d'un utilisateur UnionFlow.
* *
* <p> * <p>
* Un utilisateur possède un seul compte sur toute la plateforme. * Un utilisateur possède un seul compte sur toute la plateforme.
* Ses adhésions aux organisations sont gérées dans {@link MembreOrganisation}. * Ses adhésions aux organisations sont gérées dans {@link MembreOrganisation}.
* *
* <p> * <p>
* Table : {@code utilisateurs} * Table : {@code utilisateurs}
*/ */
@Entity @Entity
@Table(name = "utilisateurs", indexes = { @Table(name = "utilisateurs", indexes = {
@Index(name = "idx_utilisateur_email", columnList = "email", unique = true), @Index(name = "idx_utilisateur_email", columnList = "email", unique = true),
@Index(name = "idx_utilisateur_numero", columnList = "numero_membre", unique = true), @Index(name = "idx_utilisateur_numero", columnList = "numero_membre", unique = true),
@Index(name = "idx_utilisateur_keycloak", columnList = "keycloak_id", unique = true), @Index(name = "idx_utilisateur_keycloak", columnList = "keycloak_id", unique = true),
@Index(name = "idx_utilisateur_actif", columnList = "actif"), @Index(name = "idx_utilisateur_actif", columnList = "actif"),
@Index(name = "idx_utilisateur_statut", columnList = "statut_compte") @Index(name = "idx_utilisateur_statut", columnList = "statut_compte")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Membre extends BaseEntity { public class Membre extends BaseEntity {
/** Identifiant Keycloak (UUID du compte OIDC) */ /** Identifiant Keycloak (UUID du compte OIDC) */
@Column(name = "keycloak_id", unique = true) @Column(name = "keycloak_id", unique = true)
private UUID keycloakId; private UUID keycloakId;
/** Numéro de membre — unique globalement sur toute la plateforme */ /** Numéro de membre — unique globalement sur toute la plateforme */
@NotBlank @NotBlank
@Column(name = "numero_membre", unique = true, nullable = false, length = 20) @Column(name = "numero_membre", unique = true, nullable = false, length = 20)
private String numeroMembre; private String numeroMembre;
@NotBlank @NotBlank
@Column(name = "prenom", nullable = false, length = 100) @Column(name = "prenom", nullable = false, length = 100)
private String prenom; private String prenom;
@NotBlank @NotBlank
@Column(name = "nom", nullable = false, length = 100) @Column(name = "nom", nullable = false, length = 100)
private String nom; private String nom;
@Email @Email
@NotBlank @NotBlank
@Column(name = "email", unique = true, nullable = false, length = 255) @Column(name = "email", unique = true, nullable = false, length = 255)
private String email; private String email;
@Column(name = "telephone", length = 20) @Column(name = "telephone", length = 20)
private String telephone; private String telephone;
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)") /** Token FCM pour les notifications push Firebase. NULL si l'app mobile n'est pas installée ou si le membre a refusé les notifications. */
@Column(name = "telephone_wave", length = 20) @Column(name = "fcm_token", length = 500)
private String telephoneWave; private String fcmToken;
@NotNull /**
@Column(name = "date_naissance", nullable = false) * Numéro CMU (Couverture Maladie Universelle) Côte d'Ivoire — auto-déclaré par le membre.
private LocalDate dateNaissance; *
* <p>Obligatoire pour les organisations de type {@code MUTUELLE_SANTE} (Loi 2014-131
@Column(name = "profession", length = 100) * exige enrôlement CMU comme préalable à toute mutuelle complémentaire). Format CNAM :
private String profession; * 11 caractères alphanumériques. La vérification de la validité se fait manuellement
* (admin) faute d'API publique CNAM disponible au 2026-04-25.
@Column(name = "photo_url", length = 500) *
private String photoUrl; * @since 2026-04-25 — passage CMU à cotisation obligatoire 1er jan 2026
*/
@Builder.Default @Pattern(regexp = "^[A-Z0-9]{11}$|^$", message = "Le numéro CMU doit faire 11 caractères alphanumériques majuscules")
@Column(name = "statut_compte", nullable = false, length = 30) @Column(name = "numero_cmu", length = 11)
private String statutCompte = "EN_ATTENTE_VALIDATION"; private String numeroCMU;
/** Vrai si le membre n'a jamais changé son mot de passe généré par l'admin. */ /**
@Builder.Default * Pays de résidence (ISO-3, ex: FRA, USA, CAN). Différent de {@code nationalite} :
@Column(name = "premiere_connexion", nullable = false) * un Ivoirien (CIV) résidant en France a paysResidence=FRA. NULL ou CIV = résident UEMOA.
private Boolean premiereConnexion = true; *
* @since 2026-04-25 (P2-NEW-7)
/** */
* Statut matrimonial (domaine @Pattern(regexp = "^[A-Z]{3}$|^$", message = "Pays résidence doit être un code ISO-3")
* {@code STATUT_MATRIMONIAL} dans @Column(name = "pays_residence", length = 3)
* {@code types_reference}). private String paysResidence;
*/
@Column(name = "statut_matrimonial", length = 50) /** Numéro de passeport pour non-résidents (CNI insuffisante hors UEMOA). */
private String statutMatrimonial; @Column(name = "numero_passeport", length = 50)
private String numeroPasseport;
/** Nationalité. */
@Column(name = "nationalite", length = 100) /** NIF/SSN/SIN — reporting fiscal accord bilatéral CI ↔ pays résidence. */
private String nationalite; @Column(name = "numero_fiscal_etranger", length = 50)
private String numeroFiscalEtranger;
/**
* Type de pièce d'identité (domaine /** TRUE si le membre est diaspora (résidence ≠ UEMOA). */
* {@code TYPE_IDENTITE} dans @Builder.Default
* {@code types_reference}). @Column(name = "est_diaspora", nullable = false)
*/ private Boolean estDiaspora = false;
@Column(name = "type_identite", length = 50)
private String typeIdentite; /** Devise préférée pour affichages et notifications (XOF par défaut). */
@Builder.Default
/** Numéro de la pièce d'identité. */ @Column(name = "devise_preferee", nullable = false, length = 3)
@Column(name = "numero_identite", length = 100) private String devisePreferee = "XOF";
private String numeroIdentite;
@Pattern(regexp = "^\\+[1-9][0-9]{6,14}$", message = "Le numéro Wave doit être au format international E.164 (ex: +22507XXXXXXXX)")
/** Notes / biographie libre du membre. */ @Column(name = "telephone_wave", length = 20)
@Column(name = "notes", length = 1000) private String telephoneWave;
private String notes;
@NotNull
/** Niveau de vigilance KYC LCB-FT (SIMPLIFIE, RENFORCE). */ @Column(name = "date_naissance", nullable = false)
@Column(name = "niveau_vigilance_kyc", length = 20) private LocalDate dateNaissance;
private String niveauVigilanceKyc;
@Column(name = "profession", length = 100)
/** Statut de vérification d'identité (NON_VERIFIE, EN_COURS, VERIFIE, REFUSE). */ private String profession;
@Column(name = "statut_kyc", length = 20)
private String statutKyc; @Column(name = "photo_url", length = 500)
private String photoUrl;
/** Date de dernière vérification d'identité. */
@Column(name = "date_verification_identite") @Builder.Default
private LocalDate dateVerificationIdentite; @Column(name = "statut_compte", nullable = false, length = 30)
private String statutCompte = "EN_ATTENTE_VALIDATION";
// ── Relations ────────────────────────────────────────────────────────────
/** Vrai si le membre n'a jamais changé son mot de passe généré par l'admin. */
/** Adhésions à des organisations */ @Builder.Default
@JsonIgnore @Column(name = "premiere_connexion", nullable = false)
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private Boolean premiereConnexion = true;
@Builder.Default
private List<MembreOrganisation> membresOrganisations = new ArrayList<>(); /**
* Statut matrimonial (domaine
@JsonIgnore * {@code STATUT_MATRIMONIAL} dans
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY) * {@code types_reference}).
@Builder.Default */
private List<Adresse> adresses = new ArrayList<>(); @Column(name = "statut_matrimonial", length = 50)
private String statutMatrimonial;
@JsonIgnore
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY) /** Nationalité. */
@Builder.Default @Column(name = "nationalite", length = 100)
private List<CompteWave> comptesWave = new ArrayList<>(); private String nationalite;
@JsonIgnore /**
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY) * Type de pièce d'identité (domaine
@Builder.Default * {@code TYPE_IDENTITE} dans
private List<Paiement> paiements = new ArrayList<>(); * {@code types_reference}).
*/
// ── Méthodes métier ─────────────────────────────────────────────────────── @Column(name = "type_identite", length = 50)
private String typeIdentite;
public String getNomComplet() {
return prenom + " " + nom; /** Numéro de la pièce d'identité. */
} @Column(name = "numero_identite", length = 100)
private String numeroIdentite;
public boolean isMajeur() {
return dateNaissance != null && dateNaissance.isBefore(LocalDate.now().minusYears(18)); /** Notes / biographie libre du membre. */
} @Column(name = "notes", length = 1000)
private String notes;
public int getAge() {
return dateNaissance != null ? LocalDate.now().getYear() - dateNaissance.getYear() : 0; /** Niveau de vigilance KYC LCB-FT (SIMPLIFIE, RENFORCE). */
} @Column(name = "niveau_vigilance_kyc", length = 20)
private String niveauVigilanceKyc;
@PrePersist
protected void onCreate() { /** Statut de vérification d'identité (NON_VERIFIE, EN_COURS, VERIFIE, REFUSE). */
super.onCreate(); @Column(name = "statut_kyc", length = 20)
if (statutCompte == null) { private String statutKyc;
statutCompte = "EN_ATTENTE_VALIDATION";
} /** Date de dernière vérification d'identité. */
} @Column(name = "date_verification_identite")
} private LocalDate dateVerificationIdentite;
// ── Relations ────────────────────────────────────────────────────────────
/** Adhésions à des organisations */
@JsonIgnore
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<MembreOrganisation> membresOrganisations = new ArrayList<>();
@JsonIgnore
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<Adresse> adresses = new ArrayList<>();
@JsonIgnore
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<CompteWave> comptesWave = new ArrayList<>();
@JsonIgnore
@OneToMany(mappedBy = "membre", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default
private List<Paiement> paiements = new ArrayList<>();
// ── Méthodes métier ───────────────────────────────────────────────────────
public String getNomComplet() {
return prenom + " " + nom;
}
public boolean isMajeur() {
return dateNaissance != null && dateNaissance.isBefore(LocalDate.now().minusYears(18));
}
public int getAge() {
return dateNaissance != null ? LocalDate.now().getYear() - dateNaissance.getYear() : 0;
}
@PrePersist
protected void onCreate() {
super.onCreate();
if (statutCompte == null) {
statutCompte = "EN_ATTENTE_VALIDATION";
}
}
}

View File

@@ -1,141 +1,141 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import dev.lions.unionflow.server.api.enums.membre.StatutMembre; import dev.lions.unionflow.server.api.enums.membre.StatutMembre;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import lombok.*; import lombok.*;
/** /**
* Lien entre un utilisateur et une organisation. * Lien entre un utilisateur et une organisation.
* *
* <p>Un utilisateur peut adhérer à plusieurs organisations simultanément. * <p>Un utilisateur peut adhérer à plusieurs organisations simultanément.
* Chaque adhésion a son propre statut, date et unité d'affectation. * Chaque adhésion a son propre statut, date et unité d'affectation.
* *
* <p>Table : {@code membres_organisations} * <p>Table : {@code membres_organisations}
*/ */
@Entity @Entity
@Table( @Table(
name = "membres_organisations", name = "membres_organisations",
indexes = { indexes = {
@Index(name = "idx_mo_utilisateur", columnList = "utilisateur_id"), @Index(name = "idx_mo_utilisateur", columnList = "utilisateur_id"),
@Index(name = "idx_mo_organisation", columnList = "organisation_id"), @Index(name = "idx_mo_organisation", columnList = "organisation_id"),
@Index(name = "idx_mo_statut", columnList = "statut_membre"), @Index(name = "idx_mo_statut", columnList = "statut_membre"),
@Index(name = "idx_mo_unite", columnList = "unite_id") @Index(name = "idx_mo_unite", columnList = "unite_id")
}, },
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint( @UniqueConstraint(
name = "uk_mo_utilisateur_organisation", name = "uk_mo_utilisateur_organisation",
columnNames = {"utilisateur_id", "organisation_id"}) columnNames = {"utilisateur_id", "organisation_id"})
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class MembreOrganisation extends BaseEntity { public class MembreOrganisation extends BaseEntity {
/** L'utilisateur (identité globale) */ /** L'utilisateur (identité globale) */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "utilisateur_id", nullable = false) @JoinColumn(name = "utilisateur_id", nullable = false)
private Membre membre; private Membre membre;
/** L'organisation racine à laquelle appartient ce membre */ /** L'organisation racine à laquelle appartient ce membre */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
/** /**
* Unité d'affectation (agence/bureau). * Unité d'affectation (agence/bureau).
* NULL = affecté au siège. * NULL = affecté au siège.
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "unite_id") @JoinColumn(name = "unite_id")
private Organisation unite; private Organisation unite;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "statut_membre", nullable = false, length = 30) @Column(name = "statut_membre", nullable = false, length = 30)
private StatutMembre statutMembre = StatutMembre.EN_ATTENTE_VALIDATION; private StatutMembre statutMembre = StatutMembre.EN_ATTENTE_VALIDATION;
@Column(name = "date_adhesion") @Column(name = "date_adhesion")
private LocalDate dateAdhesion; private LocalDate dateAdhesion;
@Column(name = "date_changement_statut") @Column(name = "date_changement_statut")
private LocalDate dateChangementStatut; private LocalDate dateChangementStatut;
@Column(name = "motif_statut", length = 500) @Column(name = "motif_statut", length = 500)
private String motifStatut; private String motifStatut;
/** Utilisateur qui a approuvé ou traité ce changement de statut */ /** Utilisateur qui a approuvé ou traité ce changement de statut */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "approuve_par_id") @JoinColumn(name = "approuve_par_id")
private Membre approuvePar; private Membre approuvePar;
// ── Champs d'invitation (StatutMembre.INVITE) ────────────────────────────── // ── Champs d'invitation (StatutMembre.INVITE) ──────────────────────────────
/** Date à laquelle l'invitation a été envoyée. */ /** Date à laquelle l'invitation a été envoyée. */
@Column(name = "date_invitation") @Column(name = "date_invitation")
private LocalDateTime dateInvitation; private LocalDateTime dateInvitation;
/** Date d'expiration de l'invitation (null = pas d'expiration). */ /** Date d'expiration de l'invitation (null = pas d'expiration). */
@Column(name = "date_expiration_invitation") @Column(name = "date_expiration_invitation")
private LocalDateTime dateExpirationInvitation; private LocalDateTime dateExpirationInvitation;
/** Token opaque utilisé dans le lien d'invitation envoyé par email. */ /** Token opaque utilisé dans le lien d'invitation envoyé par email. */
@Column(name = "token_invitation", length = 64) @Column(name = "token_invitation", length = 64)
private String tokenInvitation; private String tokenInvitation;
/** ID de l'administrateur qui a envoyé l'invitation. */ /** ID de l'administrateur qui a envoyé l'invitation. */
@Column(name = "invite_par") @Column(name = "invite_par")
private UUID invitePar; private UUID invitePar;
/** Motif d'archivage (pour StatutMembre.ARCHIVE). */ /** Motif d'archivage (pour StatutMembre.ARCHIVE). */
@Column(name = "motif_archivage", length = 500) @Column(name = "motif_archivage", length = 500)
private String motifArchivage; private String motifArchivage;
// ── Rôle fonctionnel dans l'organisation ───────────────────────────────── // ── Rôle fonctionnel dans l'organisation ─────────────────────────────────
/** Rôle de ce membre dans l'organisation (ex: PRESIDENT, TRESORIER...). */ /** Rôle de ce membre dans l'organisation (ex: PRESIDENT, TRESORIER...). */
@Column(name = "role_org", length = 50) @Column(name = "role_org", length = 50)
private String roleOrg; private String roleOrg;
// ── Relations ───────────────────────────────────────────────────────────── // ── Relations ─────────────────────────────────────────────────────────────
/** Rôles de ce membre dans cette organisation */ /** Rôles de ce membre dans cette organisation */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "membreOrganisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "membreOrganisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<MembreRole> roles = new ArrayList<>(); private List<MembreRole> roles = new ArrayList<>();
/** Ayants droit (mutuelles de santé uniquement) */ /** Ayants droit (mutuelles de santé uniquement) */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "membreOrganisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "membreOrganisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<AyantDroit> ayantsDroit = new ArrayList<>(); private List<AyantDroit> ayantsDroit = new ArrayList<>();
// ── Méthodes métier ──────────────────────────────────────────────────────── // ── Méthodes métier ────────────────────────────────────────────────────────
public boolean isActif() { public boolean isActif() {
return StatutMembre.ACTIF.equals(statutMembre) && Boolean.TRUE.equals(getActif()); return StatutMembre.ACTIF.equals(statutMembre) && Boolean.TRUE.equals(getActif());
} }
public boolean peutDemanderAide() { public boolean peutDemanderAide() {
return StatutMembre.ACTIF.equals(statutMembre); return StatutMembre.ACTIF.equals(statutMembre);
} }
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (statutMembre == null) { if (statutMembre == null) {
statutMembre = StatutMembre.EN_ATTENTE_VALIDATION; statutMembre = StatutMembre.EN_ATTENTE_VALIDATION;
} }
} }
} }

View File

@@ -1,94 +1,94 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.LocalDate; import java.time.LocalDate;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Table de liaison entre Membre et Role * Table de liaison entre Membre et Role
* Permet à un membre d'avoir plusieurs rôles avec dates de début/fin * Permet à un membre d'avoir plusieurs rôles avec dates de début/fin
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "membres_roles", name = "membres_roles",
indexes = { indexes = {
@Index(name = "idx_mr_membre_org", columnList = "membre_organisation_id"), @Index(name = "idx_mr_membre_org", columnList = "membre_organisation_id"),
@Index(name = "idx_mr_organisation", columnList = "organisation_id"), @Index(name = "idx_mr_organisation", columnList = "organisation_id"),
@Index(name = "idx_mr_role", columnList = "role_id"), @Index(name = "idx_mr_role", columnList = "role_id"),
@Index(name = "idx_mr_actif", columnList = "actif") @Index(name = "idx_mr_actif", columnList = "actif")
}, },
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint( @UniqueConstraint(
name = "uk_mr_membre_org_role", name = "uk_mr_membre_org_role",
columnNames = {"membre_organisation_id", "role_id"}) columnNames = {"membre_organisation_id", "role_id"})
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class MembreRole extends BaseEntity { public class MembreRole extends BaseEntity {
/** Lien membership (utilisateur dans le contexte de son organisation) */ /** Lien membership (utilisateur dans le contexte de son organisation) */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_organisation_id", nullable = false) @JoinColumn(name = "membre_organisation_id", nullable = false)
private MembreOrganisation membreOrganisation; private MembreOrganisation membreOrganisation;
/** Organisation dans laquelle ce rôle est actif (dénormalisé pour les requêtes) */ /** Organisation dans laquelle ce rôle est actif (dénormalisé pour les requêtes) */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
/** Rôle */ /** Rôle */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false) @JoinColumn(name = "role_id", nullable = false)
private Role role; private Role role;
/** Date de début d'attribution */ /** Date de début d'attribution */
@Column(name = "date_debut") @Column(name = "date_debut")
private LocalDate dateDebut; private LocalDate dateDebut;
/** Date de fin d'attribution (null = permanent) */ /** Date de fin d'attribution (null = permanent) */
@Column(name = "date_fin") @Column(name = "date_fin")
private LocalDate dateFin; private LocalDate dateFin;
/** Commentaire sur l'attribution */ /** Commentaire sur l'attribution */
@Column(name = "commentaire", length = 500) @Column(name = "commentaire", length = 500)
private String commentaire; private String commentaire;
/** Méthode métier pour vérifier si l'attribution est active */ /** Méthode métier pour vérifier si l'attribution est active */
public boolean isActif() { public boolean isActif() {
if (!Boolean.TRUE.equals(getActif())) { if (!Boolean.TRUE.equals(getActif())) {
return false; return false;
} }
LocalDate aujourdhui = LocalDate.now(); LocalDate aujourdhui = LocalDate.now();
if (dateDebut != null && aujourdhui.isBefore(dateDebut)) { if (dateDebut != null && aujourdhui.isBefore(dateDebut)) {
return false; return false;
} }
if (dateFin != null && aujourdhui.isAfter(dateFin)) { if (dateFin != null && aujourdhui.isAfter(dateFin)) {
return false; return false;
} }
return true; return true;
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (dateDebut == null) { if (dateDebut == null) {
dateDebut = LocalDate.now(); dateDebut = LocalDate.now();
} }
} }
} }

View File

@@ -1,38 +1,38 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.util.UUID; import java.util.UUID;
/** /**
* Lien « suivi » entre deux membres : le membre connecté (follower) suit un autre membre (suivi). * Lien « suivi » entre deux membres : le membre connecté (follower) suit un autre membre (suivi).
* Utilisé pour la fonctionnalité Réseau / Suivre dans lapp mobile. * Utilisé pour la fonctionnalité Réseau / Suivre dans lapp mobile.
*/ */
@Entity @Entity
@Table( @Table(
name = "membre_suivi", name = "membre_suivi",
uniqueConstraints = @UniqueConstraint(columnNames = { "follower_utilisateur_id", "suivi_utilisateur_id" }), uniqueConstraints = @UniqueConstraint(columnNames = { "follower_utilisateur_id", "suivi_utilisateur_id" }),
indexes = { indexes = {
@Index(name = "idx_membre_suivi_follower", columnList = "follower_utilisateur_id"), @Index(name = "idx_membre_suivi_follower", columnList = "follower_utilisateur_id"),
@Index(name = "idx_membre_suivi_suivi", columnList = "suivi_utilisateur_id") @Index(name = "idx_membre_suivi_suivi", columnList = "suivi_utilisateur_id")
} }
) )
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class MembreSuivi extends BaseEntity { public class MembreSuivi extends BaseEntity {
/** Utilisateur qui suit (membre connecté). */ /** Utilisateur qui suit (membre connecté). */
@NotNull @NotNull
@Column(name = "follower_utilisateur_id", nullable = false) @Column(name = "follower_utilisateur_id", nullable = false)
private UUID followerUtilisateurId; private UUID followerUtilisateurId;
/** Utilisateur suivi (membre cible). */ /** Utilisateur suivi (membre cible). */
@NotNull @NotNull
@Column(name = "suivi_utilisateur_id", nullable = false) @Column(name = "suivi_utilisateur_id", nullable = false)
private UUID suiviUtilisateurId; private UUID suiviUtilisateurId;
} }

View File

@@ -1,56 +1,56 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import lombok.*; import lombok.*;
/** /**
* Catalogue des modules métier activables par type d'organisation. * Catalogue des modules métier activables par type d'organisation.
* *
* <p>Géré uniquement par le Super Admin UnionFlow. * <p>Géré uniquement par le Super Admin UnionFlow.
* Les organisations ne peuvent pas créer de nouveaux modules. * Les organisations ne peuvent pas créer de nouveaux modules.
* *
* <p>Table : {@code modules_disponibles} * <p>Table : {@code modules_disponibles}
*/ */
@Entity @Entity
@Table( @Table(
name = "modules_disponibles", name = "modules_disponibles",
indexes = { indexes = {
@Index(name = "idx_module_code", columnList = "code", unique = true), @Index(name = "idx_module_code", columnList = "code", unique = true),
@Index(name = "idx_module_actif", columnList = "actif") @Index(name = "idx_module_actif", columnList = "actif")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ModuleDisponible extends BaseEntity { public class ModuleDisponible extends BaseEntity {
@NotBlank @NotBlank
@Column(name = "code", unique = true, nullable = false, length = 50) @Column(name = "code", unique = true, nullable = false, length = 50)
private String code; private String code;
@NotBlank @NotBlank
@Column(name = "libelle", nullable = false, length = 150) @Column(name = "libelle", nullable = false, length = 150)
private String libelle; private String libelle;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
/** /**
* JSON array des types d'organisations compatibles. * JSON array des types d'organisations compatibles.
* Exemple : ["MUTUELLE_SANTE","ONG"] ou ["ALL"] pour tous. * Exemple : ["MUTUELLE_SANTE","ONG"] ou ["ALL"] pour tous.
*/ */
@Column(name = "types_org_compatibles", columnDefinition = "TEXT") @Column(name = "types_org_compatibles", columnDefinition = "TEXT")
private String typesOrgCompatibles; private String typesOrgCompatibles;
@Builder.Default @Builder.Default
@Column(name = "ordre_affichage", nullable = false) @Column(name = "ordre_affichage", nullable = false)
private Integer ordreAffichage = 0; private Integer ordreAffichage = 0;
public boolean estCompatibleAvec(String typeOrganisation) { public boolean estCompatibleAvec(String typeOrganisation) {
if (typesOrgCompatibles == null) return false; if (typesOrgCompatibles == null) return false;
return typesOrgCompatibles.contains("ALL") return typesOrgCompatibles.contains("ALL")
|| typesOrgCompatibles.contains(typeOrganisation); || typesOrgCompatibles.contains(typeOrganisation);
} }
} }

View File

@@ -1,64 +1,64 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.*; import lombok.*;
/** /**
* Module activé pour une organisation donnée. * Module activé pour une organisation donnée.
* *
* <p> * <p>
* Les modules sont activés automatiquement selon le type d'organisation * Les modules sont activés automatiquement selon le type d'organisation
* lors de la première souscription, et peuvent être désactivés par le manager. * lors de la première souscription, et peuvent être désactivés par le manager.
* *
* <p> * <p>
* Table : {@code modules_organisation_actifs} * Table : {@code modules_organisation_actifs}
*/ */
@Entity @Entity
@Table(name = "modules_organisation_actifs", indexes = { @Table(name = "modules_organisation_actifs", indexes = {
@Index(name = "idx_moa_organisation", columnList = "organisation_id"), @Index(name = "idx_moa_organisation", columnList = "organisation_id"),
@Index(name = "idx_moa_module", columnList = "module_code") @Index(name = "idx_moa_module", columnList = "module_code")
}, uniqueConstraints = { }, uniqueConstraints = {
@UniqueConstraint(name = "uk_moa_org_module", columnNames = { "organisation_id", "module_code" }) @UniqueConstraint(name = "uk_moa_org_module", columnNames = { "organisation_id", "module_code" })
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ModuleOrganisationActif extends BaseEntity { public class ModuleOrganisationActif extends BaseEntity {
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotBlank @NotBlank
@Column(name = "module_code", nullable = false, length = 50) @Column(name = "module_code", nullable = false, length = 50)
private String moduleCode; private String moduleCode;
/** /**
* Référence vers le catalogue des modules. * Référence vers le catalogue des modules.
* Assure l'intégrité référentielle avec * Assure l'intégrité référentielle avec
* {@code modules_disponibles}. * {@code modules_disponibles}.
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "module_disponible_id") @JoinColumn(name = "module_disponible_id")
private ModuleDisponible moduleDisponible; private ModuleDisponible moduleDisponible;
@Builder.Default @Builder.Default
@Column(name = "date_activation", nullable = false) @Column(name = "date_activation", nullable = false)
private LocalDateTime dateActivation = LocalDateTime.now(); private LocalDateTime dateActivation = LocalDateTime.now();
/** /**
* Configuration JSON spécifique au module pour cette organisation. * Configuration JSON spécifique au module pour cette organisation.
* Exemple pour CREDIT_EPARGNE : {"taux_interet_max": 18, "duree_max_mois": 24} * Exemple pour CREDIT_EPARGNE : {"taux_interet_max": 18, "duree_max_mois": 24}
*/ */
@Column(name = "parametres", columnDefinition = "TEXT") @Column(name = "parametres", columnDefinition = "TEXT")
private String parametres; private String parametres;
public boolean isActif() { public boolean isActif() {
return Boolean.TRUE.equals(getActif()); return Boolean.TRUE.equals(getActif());
} }
} }

View File

@@ -1,123 +1,123 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Notification pour la gestion des notifications * Entité Notification pour la gestion des notifications
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table(name = "notifications", indexes = { @Table(name = "notifications", indexes = {
@Index(name = "idx_notification_type", columnList = "type_notification"), @Index(name = "idx_notification_type", columnList = "type_notification"),
@Index(name = "idx_notification_statut", columnList = "statut"), @Index(name = "idx_notification_statut", columnList = "statut"),
@Index(name = "idx_notification_priorite", columnList = "priorite"), @Index(name = "idx_notification_priorite", columnList = "priorite"),
@Index(name = "idx_notification_membre", columnList = "membre_id"), @Index(name = "idx_notification_membre", columnList = "membre_id"),
@Index(name = "idx_notification_organisation", columnList = "organisation_id"), @Index(name = "idx_notification_organisation", columnList = "organisation_id"),
@Index(name = "idx_notification_date_envoi", columnList = "date_envoi") @Index(name = "idx_notification_date_envoi", columnList = "date_envoi")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Notification extends BaseEntity { public class Notification extends BaseEntity {
/** Type de notification */ /** Type de notification */
@NotNull @NotNull
@Column(name = "type_notification", nullable = false, length = 30) @Column(name = "type_notification", nullable = false, length = 30)
private String typeNotification; private String typeNotification;
/** Priorité */ /** Priorité */
@Builder.Default @Builder.Default
@Column(name = "priorite", length = 20) @Column(name = "priorite", length = 20)
private String priorite = "NORMALE"; private String priorite = "NORMALE";
/** Statut */ /** Statut */
@Builder.Default @Builder.Default
@Column(name = "statut", length = 30) @Column(name = "statut", length = 30)
private String statut = "EN_ATTENTE"; private String statut = "EN_ATTENTE";
/** Sujet */ /** Sujet */
@Column(name = "sujet", length = 500) @Column(name = "sujet", length = 500)
private String sujet; private String sujet;
/** Corps du message */ /** Corps du message */
@Column(name = "corps", columnDefinition = "TEXT") @Column(name = "corps", columnDefinition = "TEXT")
private String corps; private String corps;
/** Date d'envoi prévue */ /** Date d'envoi prévue */
@Column(name = "date_envoi_prevue") @Column(name = "date_envoi_prevue")
private LocalDateTime dateEnvoiPrevue; private LocalDateTime dateEnvoiPrevue;
/** Date d'envoi réelle */ /** Date d'envoi réelle */
@Column(name = "date_envoi") @Column(name = "date_envoi")
private LocalDateTime dateEnvoi; private LocalDateTime dateEnvoi;
/** Date de lecture */ /** Date de lecture */
@Column(name = "date_lecture") @Column(name = "date_lecture")
private LocalDateTime dateLecture; private LocalDateTime dateLecture;
/** Nombre de tentatives d'envoi */ /** Nombre de tentatives d'envoi */
@Builder.Default @Builder.Default
@Column(name = "nombre_tentatives", nullable = false) @Column(name = "nombre_tentatives", nullable = false)
private Integer nombreTentatives = 0; private Integer nombreTentatives = 0;
/** Message d'erreur (si échec) */ /** Message d'erreur (si échec) */
@Column(name = "message_erreur", length = 1000) @Column(name = "message_erreur", length = 1000)
private String messageErreur; private String messageErreur;
/** Données additionnelles (JSON) */ /** Données additionnelles (JSON) */
@Column(name = "donnees_additionnelles", columnDefinition = "TEXT") @Column(name = "donnees_additionnelles", columnDefinition = "TEXT")
private String donneesAdditionnelles; private String donneesAdditionnelles;
// Relations // Relations
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id") @JoinColumn(name = "membre_id")
private Membre membre; private Membre membre;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "template_id") @JoinColumn(name = "template_id")
private TemplateNotification template; private TemplateNotification template;
/** Méthode métier pour vérifier si la notification est envoyée */ /** Méthode métier pour vérifier si la notification est envoyée */
public boolean isEnvoyee() { public boolean isEnvoyee() {
return statut != null && dev.lions.unionflow.server.api.enums.notification.StatutNotification.ENVOYEE.name().equals(statut); return statut != null && dev.lions.unionflow.server.api.enums.notification.StatutNotification.ENVOYEE.name().equals(statut);
} }
/** Méthode métier pour vérifier si la notification est lue */ /** Méthode métier pour vérifier si la notification est lue */
public boolean isLue() { public boolean isLue() {
return statut != null && dev.lions.unionflow.server.api.enums.notification.StatutNotification.LUE.name().equals(statut); return statut != null && dev.lions.unionflow.server.api.enums.notification.StatutNotification.LUE.name().equals(statut);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (priorite == null) { if (priorite == null) {
priorite = "NORMALE"; priorite = "NORMALE";
} }
if (statut == null) { if (statut == null) {
statut = "EN_ATTENTE"; statut = "EN_ATTENTE";
} }
if (nombreTentatives == null) { if (nombreTentatives == null) {
nombreTentatives = 0; nombreTentatives = 0;
} }
if (dateEnvoiPrevue == null) { if (dateEnvoiPrevue == null) {
dateEnvoiPrevue = LocalDateTime.now(); dateEnvoiPrevue = LocalDateTime.now();
} }
} }
} }

View File

@@ -1,326 +1,355 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.Period; import java.time.Period;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import java.util.UUID;
import lombok.Builder; import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Builder;
import lombok.EqualsAndHashCode; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Entité Organisation avec UUID Représente une organisation (Lions Club, /**
* Association, * Entité Organisation avec UUID Représente une organisation (Lions Club,
* Coopérative, etc.) * Association,
* * Coopérative, etc.)
* @author UnionFlow Team *
* @version 2.0 * @author UnionFlow Team
* @since 2025-01-16 * @version 2.0
*/ * @since 2025-01-16
@Entity */
@Table(name = "organisations", indexes = { @Entity
@Index(name = "idx_organisation_nom", columnList = "nom"), @Table(name = "organisations", indexes = {
@Index(name = "idx_organisation_email", columnList = "email", unique = true), @Index(name = "idx_organisation_nom", columnList = "nom"),
@Index(name = "idx_organisation_statut", columnList = "statut"), @Index(name = "idx_organisation_email", columnList = "email", unique = true),
@Index(name = "idx_organisation_type", columnList = "type_organisation"), @Index(name = "idx_organisation_statut", columnList = "statut"),
@Index(name = "idx_organisation_parente", columnList = "organisation_parente_id"), @Index(name = "idx_organisation_type", columnList = "type_organisation"),
@Index(name = "idx_organisation_numero_enregistrement", columnList = "numero_enregistrement", unique = true) @Index(name = "idx_organisation_parente", columnList = "organisation_parente_id"),
}) @Index(name = "idx_organisation_numero_enregistrement", columnList = "numero_enregistrement", unique = true)
@Data })
@NoArgsConstructor @Data
@AllArgsConstructor @NoArgsConstructor
@Builder @AllArgsConstructor
@EqualsAndHashCode(callSuper = true) @Builder
public class Organisation extends BaseEntity { @EqualsAndHashCode(callSuper = true)
public class Organisation extends BaseEntity {
@NotBlank
@Column(name = "nom", nullable = false, length = 200) @NotBlank
private String nom; @Column(name = "nom", nullable = false, length = 200)
private String nom;
@Column(name = "nom_court", length = 50)
private String nomCourt; @Column(name = "nom_court", length = 50)
private String nomCourt;
@NotBlank
@Column(name = "type_organisation", nullable = false, length = 50) @NotBlank
private String typeOrganisation; @Column(name = "type_organisation", nullable = false, length = 50)
private String typeOrganisation;
@NotBlank
@Column(name = "statut", nullable = false, length = 50) @NotBlank
private String statut; @Column(name = "statut", nullable = false, length = 50)
private String statut;
@Column(name = "description", length = 2000)
private String description; @Column(name = "description", length = 2000)
private String description;
@Column(name = "date_fondation")
private LocalDate dateFondation; @Column(name = "date_fondation")
private LocalDate dateFondation;
@Column(name = "numero_enregistrement", unique = true, length = 100)
private String numeroEnregistrement; @Column(name = "numero_enregistrement", unique = true, length = 100)
private String numeroEnregistrement;
// Informations de contact
@Email // Informations de contact
@NotBlank @Email
@Column(name = "email", unique = true, nullable = false, length = 255) @NotBlank
private String email; @Column(name = "email", unique = true, nullable = false, length = 255)
private String email;
@Column(name = "telephone", length = 20)
private String telephone; @Column(name = "telephone", length = 20)
private String telephone;
@Column(name = "telephone_secondaire", length = 20)
private String telephoneSecondaire; @Column(name = "telephone_secondaire", length = 20)
private String telephoneSecondaire;
@Email
@Column(name = "email_secondaire", length = 255) @Email
private String emailSecondaire; @Column(name = "email_secondaire", length = 255)
private String emailSecondaire;
// Adresse principale (champs dénormalisés pour performance)
@Column(name = "adresse", length = 500) // Adresse principale (champs dénormalisés pour performance)
private String adresse; @Column(name = "adresse", length = 500)
private String adresse;
@Column(name = "ville", length = 100)
private String ville; @Column(name = "ville", length = 100)
private String ville;
@Column(name = "region", length = 100)
private String region; @Column(name = "region", length = 100)
private String region;
@Column(name = "pays", length = 100)
private String pays; @Column(name = "pays", length = 100)
private String pays;
@Column(name = "code_postal", length = 20)
private String codePostal; @Column(name = "code_postal", length = 20)
private String codePostal;
// Coordonnées géographiques
@DecimalMin(value = "-90.0", message = "La latitude doit être comprise entre -90 et 90") // Coordonnées géographiques
@DecimalMax(value = "90.0", message = "La latitude doit être comprise entre -90 et 90") @DecimalMin(value = "-90.0", message = "La latitude doit être comprise entre -90 et 90")
@Digits(integer = 3, fraction = 6) @DecimalMax(value = "90.0", message = "La latitude doit être comprise entre -90 et 90")
@Column(name = "latitude", precision = 9, scale = 6) @Digits(integer = 3, fraction = 6)
private BigDecimal latitude; @Column(name = "latitude", precision = 9, scale = 6)
private BigDecimal latitude;
@DecimalMin(value = "-180.0", message = "La longitude doit être comprise entre -180 et 180")
@DecimalMax(value = "180.0", message = "La longitude doit être comprise entre -180 et 180") @DecimalMin(value = "-180.0", message = "La longitude doit être comprise entre -180 et 180")
@Digits(integer = 3, fraction = 6) @DecimalMax(value = "180.0", message = "La longitude doit être comprise entre -180 et 180")
@Column(name = "longitude", precision = 9, scale = 6) @Digits(integer = 3, fraction = 6)
private BigDecimal longitude; @Column(name = "longitude", precision = 9, scale = 6)
private BigDecimal longitude;
// Web et réseaux sociaux
@Column(name = "site_web", length = 500) // Web et réseaux sociaux
private String siteWeb; @Column(name = "site_web", length = 500)
private String siteWeb;
@Column(name = "logo", length = 500)
private String logo; @Column(name = "logo", length = 500)
private String logo;
@Column(name = "reseaux_sociaux", length = 1000)
private String reseauxSociaux; @Column(name = "reseaux_sociaux", length = 1000)
private String reseauxSociaux;
// ── Hiérarchie ──────────────────────────────────────────────────────────────
// ── Hiérarchie ──────────────────────────────────────────────────────────────
/** Organisation parente — FK propre (null = organisation racine) */
@ManyToOne(fetch = FetchType.LAZY) /** Organisation parente — FK propre (null = organisation racine) */
@JoinColumn(name = "organisation_parente_id") @ManyToOne(fetch = FetchType.LAZY)
private Organisation organisationParente; @JoinColumn(name = "organisation_parente_id")
private Organisation organisationParente;
@Builder.Default
@Column(name = "niveau_hierarchique", nullable = false) @Builder.Default
private Integer niveauHierarchique = 0; @Column(name = "niveau_hierarchique", nullable = false)
private Integer niveauHierarchique = 0;
/**
* TRUE si c'est l'organisation racine qui porte la souscription SaaS /**
* pour toute sa hiérarchie. * TRUE si c'est l'organisation racine qui porte la souscription SaaS
*/ * pour toute sa hiérarchie.
@Builder.Default */
@Column(name = "est_organisation_racine", nullable = false) @Builder.Default
private Boolean estOrganisationRacine = true; @Column(name = "est_organisation_racine", nullable = false)
private Boolean estOrganisationRacine = true;
/**
* Chemin hiérarchique complet — ex: /uuid-racine/uuid-intermediate/uuid-feuille /**
* Permet des requêtes récursives optimisées sans CTE. * Chemin hiérarchique complet — ex: /uuid-racine/uuid-intermediate/uuid-feuille
*/ * Permet des requêtes récursives optimisées sans CTE.
@Column(name = "chemin_hierarchique", length = 2000) */
private String cheminHierarchique; @Column(name = "chemin_hierarchique", length = 2000)
private String cheminHierarchique;
// Statistiques
@Builder.Default // Statistiques
@Column(name = "nombre_membres", nullable = false) @Builder.Default
private Integer nombreMembres = 0; @Column(name = "nombre_membres", nullable = false)
private Integer nombreMembres = 0;
@Builder.Default
@Column(name = "nombre_administrateurs", nullable = false) @Builder.Default
private Integer nombreAdministrateurs = 0; @Column(name = "nombre_administrateurs", nullable = false)
private Integer nombreAdministrateurs = 0;
// Finances
@DecimalMin(value = "0.0", message = "Le budget annuel doit être positif") // Finances
@Digits(integer = 12, fraction = 2) @DecimalMin(value = "0.0", message = "Le budget annuel doit être positif")
@Column(name = "budget_annuel", precision = 14, scale = 2) @Digits(integer = 12, fraction = 2)
private BigDecimal budgetAnnuel; @Column(name = "budget_annuel", precision = 14, scale = 2)
private BigDecimal budgetAnnuel;
@Builder.Default
@Column(name = "devise", length = 3) @Builder.Default
private String devise = "XOF"; @Column(name = "devise", length = 3)
private String devise = "XOF";
@Builder.Default
@Column(name = "cotisation_obligatoire", nullable = false) @Builder.Default
private Boolean cotisationObligatoire = false; @Column(name = "cotisation_obligatoire", nullable = false)
private Boolean cotisationObligatoire = false;
@DecimalMin(value = "0.0", message = "Le montant de cotisation doit être positif")
@Digits(integer = 10, fraction = 2) @DecimalMin(value = "0.0", message = "Le montant de cotisation doit être positif")
@Column(name = "montant_cotisation_annuelle", precision = 12, scale = 2) @Digits(integer = 10, fraction = 2)
private BigDecimal montantCotisationAnnuelle; @Column(name = "montant_cotisation_annuelle", precision = 12, scale = 2)
private BigDecimal montantCotisationAnnuelle;
// Informations complémentaires
@Column(name = "objectifs", length = 2000) // Informations complémentaires
private String objectifs; @Column(name = "objectifs", length = 2000)
private String objectifs;
@Column(name = "activites_principales", length = 2000)
private String activitesPrincipales; @Column(name = "activites_principales", length = 2000)
private String activitesPrincipales;
@Column(name = "certifications", length = 500)
private String certifications; @Column(name = "certifications", length = 500)
private String certifications;
@Column(name = "partenaires", length = 1000)
private String partenaires; @Column(name = "partenaires", length = 1000)
private String partenaires;
@Column(name = "notes", length = 1000)
private String notes; @Column(name = "notes", length = 1000)
private String notes;
// Paramètres
@Builder.Default // Paramètres
@Column(name = "organisation_publique", nullable = false) @Builder.Default
private Boolean organisationPublique = true; @Column(name = "organisation_publique", nullable = false)
private Boolean organisationPublique = true;
@Builder.Default
@Column(name = "accepte_nouveaux_membres", nullable = false) @Builder.Default
private Boolean accepteNouveauxMembres = true; @Column(name = "accepte_nouveaux_membres", nullable = false)
private Boolean accepteNouveauxMembres = true;
/** Catégorie du type d'organisation (ASSOCIATIF, FINANCIER_SOLIDAIRE, RELIGIEUX, PROFESSIONNEL, RESEAU_FEDERATION) */
@Column(name = "categorie_type", length = 50) /** Catégorie du type d'organisation (ASSOCIATIF, FINANCIER_SOLIDAIRE, RELIGIEUX, PROFESSIONNEL, RESEAU_FEDERATION) */
private String categorieType; @Column(name = "categorie_type", length = 50)
private String categorieType;
/** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */
@Column(name = "modules_actifs", length = 1000) /** ID de l'Organization Keycloak 26 correspondante — null si pas encore migrée. */
private String modulesActifs; @Column(name = "keycloak_org_id")
private UUID keycloakOrgId;
// Relations
/** Modules activés pour cette organisation (liste CSV, ex: "MEMBRES,COTISATIONS,TONTINE") */
/** Adhésions des membres à cette organisation */ @Column(name = "modules_actifs", length = 1000)
@JsonIgnore private String modulesActifs;
@OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default /**
private List<MembreOrganisation> membresOrganisations = new ArrayList<>(); * Référentiel comptable applicable à cette organisation.
*
@JsonIgnore * <p>Détermine quel plan comptable est appliqué et quels états financiers sont générés
@OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY) * (bilan, compte de résultat, annexes). Mappage par défaut depuis {@code typeOrganisation}
@Builder.Default * via {@link ReferentielComptable#defaultFor(String)} ; l'admin peut overrider manuellement.
private List<Adresse> adresses = new ArrayList<>(); *
* @since 2026-04-25 — découverte SYCEBNL (11ᵉ Acte uniforme OHADA en vigueur 1er jan 2024)
@JsonIgnore */
@OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @Enumerated(EnumType.STRING)
@Builder.Default @Column(name = "referentiel_comptable", nullable = false, length = 20)
private List<CompteWave> comptesWave = new ArrayList<>(); @Builder.Default
private ReferentielComptable referentielComptable = ReferentielComptable.SYSCOHADA;
/** Méthode métier pour obtenir le nom complet avec sigle */
public String getNomComplet() { /**
if (nomCourt != null && !nomCourt.isEmpty()) { * UUID du membre désigné comme Compliance Officer de l'organisation (rôle obligatoire selon
return nom + " (" + nomCourt + ")"; * Instruction BCEAO 001-03-2025). Doit être rattaché à la direction générale, distinct du
} * trésorier (séparation des pouvoirs).
return nom; *
} * @since 2026-04-25 — Instruction BCEAO 001-03-2025 (LBC/FT)
*/
/** Méthode métier pour calculer l'ancienneté en années */ @Column(name = "compliance_officer_id")
public int getAncienneteAnnees() { private UUID complianceOfficerId;
if (dateFondation == null) {
return 0; // Relations
}
return Period.between(dateFondation, LocalDate.now()).getYears(); /** Adhésions des membres à cette organisation */
} @JsonIgnore
@OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
/** @Builder.Default
* Méthode métier pour vérifier si l'organisation est récente (moins de 2 ans) private List<MembreOrganisation> membresOrganisations = new ArrayList<>();
*/
public boolean isRecente() { @JsonIgnore
return getAncienneteAnnees() < 2; @OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
} @Builder.Default
private List<Adresse> adresses = new ArrayList<>();
/** Méthode métier pour vérifier si l'organisation est active */
public boolean isActive() { @JsonIgnore
return "ACTIVE".equals(statut) && Boolean.TRUE.equals(getActif()); @OneToMany(mappedBy = "organisation", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
} @Builder.Default
private List<CompteWave> comptesWave = new ArrayList<>();
/** Méthode métier pour ajouter un membre */
public void ajouterMembre() { /** Méthode métier pour obtenir le nom complet avec sigle */
if (nombreMembres == null) { public String getNomComplet() {
nombreMembres = 0; if (nomCourt != null && !nomCourt.isEmpty()) {
} return nom + " (" + nomCourt + ")";
nombreMembres++; }
} return nom;
}
/** Méthode métier pour retirer un membre */
public void retirerMembre() { /** Méthode métier pour calculer l'ancienneté en années */
if (nombreMembres != null && nombreMembres > 0) { public int getAncienneteAnnees() {
nombreMembres--; if (dateFondation == null) {
} return 0;
} }
return Period.between(dateFondation, LocalDate.now()).getYears();
/** Méthode métier pour activer l'organisation */ }
public void activer(String utilisateur) {
this.statut = "ACTIVE"; /**
this.setActif(true); * Méthode métier pour vérifier si l'organisation est récente (moins de 2 ans)
marquerCommeModifie(utilisateur); */
} public boolean isRecente() {
return getAncienneteAnnees() < 2;
/** Méthode métier pour suspendre l'organisation */ }
public void suspendre(String utilisateur) {
this.statut = "SUSPENDUE"; /** Méthode métier pour vérifier si l'organisation est active */
this.accepteNouveauxMembres = false; public boolean isActive() {
marquerCommeModifie(utilisateur); return "ACTIVE".equals(statut) && Boolean.TRUE.equals(getActif());
} }
/** Méthode métier pour dissoudre l'organisation */ /** Méthode métier pour ajouter un membre */
public void dissoudre(String utilisateur) { public void ajouterMembre() {
this.statut = "DISSOUTE"; if (nombreMembres == null) {
this.setActif(false); nombreMembres = 0;
this.accepteNouveauxMembres = false; }
marquerCommeModifie(utilisateur); nombreMembres++;
} }
/** Callback JPA avant la persistance */ /** Méthode métier pour retirer un membre */
@PrePersist public void retirerMembre() {
protected void onCreate() { if (nombreMembres != null && nombreMembres > 0) {
super.onCreate(); // Appelle le onCreate de BaseEntity nombreMembres--;
if (statut == null) { }
statut = "ACTIVE"; }
}
if (typeOrganisation == null) { /** Méthode métier pour activer l'organisation */
typeOrganisation = "ASSOCIATION"; public void activer(String utilisateur) {
} this.statut = "ACTIVE";
if (devise == null) { this.setActif(true);
devise = "XOF"; marquerCommeModifie(utilisateur);
} }
if (niveauHierarchique == null) {
niveauHierarchique = 0; /** Méthode métier pour suspendre l'organisation */
} public void suspendre(String utilisateur) {
if (estOrganisationRacine == null) { this.statut = "SUSPENDUE";
estOrganisationRacine = (organisationParente == null); this.accepteNouveauxMembres = false;
} marquerCommeModifie(utilisateur);
if (nombreMembres == null) { }
nombreMembres = 0;
} /** Méthode métier pour dissoudre l'organisation */
if (nombreAdministrateurs == null) { public void dissoudre(String utilisateur) {
nombreAdministrateurs = 0; this.statut = "DISSOUTE";
} this.setActif(false);
if (organisationPublique == null) { this.accepteNouveauxMembres = false;
organisationPublique = true; marquerCommeModifie(utilisateur);
} }
if (accepteNouveauxMembres == null) {
accepteNouveauxMembres = true; /** Callback JPA avant la persistance */
} @PrePersist
if (cotisationObligatoire == null) { protected void onCreate() {
cotisationObligatoire = false; super.onCreate(); // Appelle le onCreate de BaseEntity
} if (statut == null) {
} statut = "ACTIVE";
} }
if (typeOrganisation == null) {
typeOrganisation = "ASSOCIATION";
}
if (devise == null) {
devise = "XOF";
}
if (niveauHierarchique == null) {
niveauHierarchique = 0;
}
if (estOrganisationRacine == null) {
estOrganisationRacine = (organisationParente == null);
}
if (nombreMembres == null) {
nombreMembres = 0;
}
if (nombreAdministrateurs == null) {
nombreAdministrateurs = 0;
}
if (organisationPublique == null) {
organisationPublique = true;
}
if (accepteNouveauxMembres == null) {
accepteNouveauxMembres = true;
}
if (cotisationObligatoire == null) {
cotisationObligatoire = false;
}
}
}

View File

@@ -1,94 +1,94 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import lombok.*; import lombok.*;
/** /**
* Paramètres de cotisation configurés par le manager de chaque organisation. * Paramètres de cotisation configurés par le manager de chaque organisation.
* *
* <p> * <p>
* Le manager peut définir : * Le manager peut définir :
* <ul> * <ul>
* <li>Le montant mensuel et annuel fixé pour tous les membres</li> * <li>Le montant mensuel et annuel fixé pour tous les membres</li>
* <li>La date de départ du calcul des impayés (configurable)</li> * <li>La date de départ du calcul des impayés (configurable)</li>
* <li>Le délai en jours avant passage automatique en statut INACTIF</li> * <li>Le délai en jours avant passage automatique en statut INACTIF</li>
* </ul> * </ul>
* *
* <p> * <p>
* Table : {@code parametres_cotisation_organisation} * Table : {@code parametres_cotisation_organisation}
*/ */
@Entity @Entity
@Table(name = "parametres_cotisation_organisation", indexes = { @Table(name = "parametres_cotisation_organisation", indexes = {
@Index(name = "idx_param_cot_org", columnList = "organisation_id", unique = true) @Index(name = "idx_param_cot_org", columnList = "organisation_id", unique = true)
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ParametresCotisationOrganisation extends BaseEntity { public class ParametresCotisationOrganisation extends BaseEntity {
@NotNull @NotNull
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false, unique = true) @JoinColumn(name = "organisation_id", nullable = false, unique = true)
private Organisation organisation; private Organisation organisation;
@Builder.Default @Builder.Default
@DecimalMin("0.00") @DecimalMin("0.00")
@Digits(integer = 10, fraction = 2) @Digits(integer = 10, fraction = 2)
@Column(name = "montant_cotisation_mensuelle", precision = 12, scale = 2) @Column(name = "montant_cotisation_mensuelle", precision = 12, scale = 2)
private BigDecimal montantCotisationMensuelle = BigDecimal.ZERO; private BigDecimal montantCotisationMensuelle = BigDecimal.ZERO;
@Builder.Default @Builder.Default
@DecimalMin("0.00") @DecimalMin("0.00")
@Digits(integer = 10, fraction = 2) @Digits(integer = 10, fraction = 2)
@Column(name = "montant_cotisation_annuelle", precision = 12, scale = 2) @Column(name = "montant_cotisation_annuelle", precision = 12, scale = 2)
private BigDecimal montantCotisationAnnuelle = BigDecimal.ZERO; private BigDecimal montantCotisationAnnuelle = BigDecimal.ZERO;
@Column(name = "devise", nullable = false, length = 3) @Column(name = "devise", nullable = false, length = 3)
private String devise; private String devise;
/** /**
* Date de référence pour le calcul des membres «à jour». * Date de référence pour le calcul des membres «à jour».
* Toutes les échéances depuis cette date doivent être payées. * Toutes les échéances depuis cette date doivent être payées.
* Configurable par le manager. * Configurable par le manager.
*/ */
@Column(name = "date_debut_calcul_ajour") @Column(name = "date_debut_calcul_ajour")
private LocalDate dateDebutCalculAjour; private LocalDate dateDebutCalculAjour;
/** /**
* Nombre de jours de retard avant passage automatique du statut membre → * Nombre de jours de retard avant passage automatique du statut membre →
* INACTIF. * INACTIF.
* Défaut : 30 jours. * Défaut : 30 jours.
*/ */
@Builder.Default @Builder.Default
@Min(1) @Min(1)
@Column(name = "delai_retard_avant_inactif_jours", nullable = false) @Column(name = "delai_retard_avant_inactif_jours", nullable = false)
private Integer delaiRetardAvantInactifJours = 30; private Integer delaiRetardAvantInactifJours = 30;
@Builder.Default @Builder.Default
@Column(name = "cotisation_obligatoire", nullable = false) @Column(name = "cotisation_obligatoire", nullable = false)
private Boolean cotisationObligatoire = true; private Boolean cotisationObligatoire = true;
/** /**
* Active la génération automatique mensuelle des cotisations pour cette organisation. * Active la génération automatique mensuelle des cotisations pour cette organisation.
* Quand {@code true}, un job planifié crée automatiquement une cotisation par membre actif * Quand {@code true}, un job planifié crée automatiquement une cotisation par membre actif
* le 1er de chaque mois, en utilisant les barèmes par rôle ou le montant par défaut. * le 1er de chaque mois, en utilisant les barèmes par rôle ou le montant par défaut.
*/ */
@Builder.Default @Builder.Default
@Column(name = "generation_automatique_activee", nullable = false) @Column(name = "generation_automatique_activee", nullable = false)
private Boolean generationAutomatiqueActivee = false; private Boolean generationAutomatiqueActivee = false;
// ── Méthodes métier ──────────────────────────────────────────────────────── // ── Méthodes métier ────────────────────────────────────────────────────────
/** /**
* Vérifie si la date de référence pour les impayés est définie. * Vérifie si la date de référence pour les impayés est définie.
* Sans cette date, aucun calcul d'ancienneté des impayés n'est possible. * Sans cette date, aucun calcul d'ancienneté des impayés n'est possible.
*/ */
public boolean isCalculAjourActive() { public boolean isCalculAjourActive() {
return dateDebutCalculAjour != null; return dateDebutCalculAjour != null;
} }
} }

View File

@@ -1,36 +1,36 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.UUID; import java.util.UUID;
/** /**
* Paramètres LCB-FT par organisation ou globaux (organisationId null). * Paramètres LCB-FT par organisation ou globaux (organisationId null).
* Seuils au-dessus desquels l'origine des fonds est obligatoire / validation manuelle. * Seuils au-dessus desquels l'origine des fonds est obligatoire / validation manuelle.
*/ */
@Entity @Entity
@Table(name = "parametres_lcb_ft", indexes = { @Table(name = "parametres_lcb_ft", indexes = {
@Index(name = "idx_param_lcb_ft_org", columnList = "organisation_id") @Index(name = "idx_param_lcb_ft_org", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ParametresLcbFt extends BaseEntity { public class ParametresLcbFt extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
@Column(name = "code_devise", nullable = false, length = 3) @Column(name = "code_devise", nullable = false, length = 3)
private String codeDevise; private String codeDevise;
@Column(name = "montant_seuil_justification", nullable = false, precision = 18, scale = 4) @Column(name = "montant_seuil_justification", nullable = false, precision = 18, scale = 4)
private BigDecimal montantSeuilJustification; private BigDecimal montantSeuilJustification;
@Column(name = "montant_seuil_validation_manuelle", precision = 18, scale = 4) @Column(name = "montant_seuil_validation_manuelle", precision = 18, scale = 4)
private BigDecimal montantSeuilValidationManuelle; private BigDecimal montantSeuilValidationManuelle;
} }

View File

@@ -0,0 +1,56 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Participation d'un membre à une session de formation LBC/FT.
*
* @since 2026-04-25 (P1-NEW-12)
*/
@Entity
@Table(name = "participations_formation_lbcft",
uniqueConstraints = @UniqueConstraint(columnNames = {"formation_id", "membre_id"}),
indexes = {
@Index(name = "idx_participation_membre", columnList = "membre_id"),
@Index(name = "idx_participation_statut", columnList = "statut_participation")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ParticipationFormationLbcFt extends BaseEntity {
@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "formation_id", nullable = false)
private FormationLbcFt formation;
@NotNull
@Column(name = "membre_id", nullable = false)
private UUID membreId;
@NotBlank
@Column(name = "statut_participation", nullable = false, length = 20)
@Builder.Default
private String statutParticipation = "INSCRIT"; // INSCRIT, PRESENT, ABSENT, CERTIFIE
@Column(name = "date_certification")
private LocalDateTime dateCertification;
@Column(name = "numero_certificat", length = 100)
private String numeroCertificat;
@Column(name = "score_quiz", precision = 5, scale = 2)
private BigDecimal scoreQuiz;
}

View File

@@ -1,92 +1,92 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Permission pour la gestion des permissions granulaires * Entité Permission pour la gestion des permissions granulaires
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "permissions", name = "permissions",
indexes = { indexes = {
@Index(name = "idx_permission_code", columnList = "code", unique = true), @Index(name = "idx_permission_code", columnList = "code", unique = true),
@Index(name = "idx_permission_module", columnList = "module"), @Index(name = "idx_permission_module", columnList = "module"),
@Index(name = "idx_permission_ressource", columnList = "ressource") @Index(name = "idx_permission_ressource", columnList = "ressource")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Permission extends BaseEntity { public class Permission extends BaseEntity {
/** Code unique de la permission (format: MODULE > RESSOURCE > ACTION) */ /** Code unique de la permission (format: MODULE > RESSOURCE > ACTION) */
@NotBlank @NotBlank
@Column(name = "code", unique = true, nullable = false, length = 100) @Column(name = "code", unique = true, nullable = false, length = 100)
private String code; private String code;
/** Module (ex: ORGANISATION, MEMBRE, COTISATION) */ /** Module (ex: ORGANISATION, MEMBRE, COTISATION) */
@NotBlank @NotBlank
@Column(name = "module", nullable = false, length = 50) @Column(name = "module", nullable = false, length = 50)
private String module; private String module;
/** Ressource (ex: MEMBRE, COTISATION, ADHESION) */ /** Ressource (ex: MEMBRE, COTISATION, ADHESION) */
@NotBlank @NotBlank
@Column(name = "ressource", nullable = false, length = 50) @Column(name = "ressource", nullable = false, length = 50)
private String ressource; private String ressource;
/** Action (ex: CREATE, READ, UPDATE, DELETE, VALIDATE) */ /** Action (ex: CREATE, READ, UPDATE, DELETE, VALIDATE) */
@NotBlank @NotBlank
@Column(name = "action", nullable = false, length = 50) @Column(name = "action", nullable = false, length = 50)
private String action; private String action;
/** Libellé de la permission */ /** Libellé de la permission */
@Column(name = "libelle", length = 200) @Column(name = "libelle", length = 200)
private String libelle; private String libelle;
/** Description de la permission */ /** Description de la permission */
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** Rôles associés */ /** Rôles associés */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "permission", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "permission", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<RolePermission> roles = new ArrayList<>(); private List<RolePermission> roles = new ArrayList<>();
/** Méthode métier pour générer le code à partir des composants */ /** Méthode métier pour générer le code à partir des composants */
public static String genererCode(String module, String ressource, String action) { public static String genererCode(String module, String ressource, String action) {
return String.format("%s > %s > %s", module.toUpperCase(), ressource.toUpperCase(), action.toUpperCase()); return String.format("%s > %s > %s", module.toUpperCase(), ressource.toUpperCase(), action.toUpperCase());
} }
/** Méthode métier pour vérifier si le code est valide */ /** Méthode métier pour vérifier si le code est valide */
public boolean isCodeValide() { public boolean isCodeValide() {
return code != null && code.contains(" > ") && code.split(" > ").length == 3; return code != null && code.contains(" > ") && code.split(" > ").length == 3;
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
// Générer le code si non fourni // Générer le code si non fourni
if (code == null || code.isEmpty()) { if (code == null || code.isEmpty()) {
if (module != null && ressource != null && action != null) { if (module != null && ressource != null && action != null) {
code = genererCode(module, ressource, action); code = genererCode(module, ressource, action);
} }
} }
} }
} }

View File

@@ -1,122 +1,122 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.Index; import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import java.util.UUID; import java.util.UUID;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Association polymorphique entre un document et * Association polymorphique entre un document et
* une entité métier quelconque. * une entité métier quelconque.
* *
* <p> * <p>
* Remplace les 6 FK nullables mutuellement * Remplace les 6 FK nullables mutuellement
* exclusives (membre, organisation, cotisation, * exclusives (membre, organisation, cotisation,
* adhesion, demandeAide, transactionWave) par un * adhesion, demandeAide, transactionWave) par un
* couple {@code (type_entite_rattachee, * couple {@code (type_entite_rattachee,
* entite_rattachee_id)}. * entite_rattachee_id)}.
* *
* <p> * <p>
* Les types autorisés sont définis dans le * Les types autorisés sont définis dans le
* domaine {@code ENTITE_RATTACHEE} de la table * domaine {@code ENTITE_RATTACHEE} de la table
* {@code types_reference} (ex: MEMBRE, * {@code types_reference} (ex: MEMBRE,
* ORGANISATION, COTISATION, ADHESION, AIDE, * ORGANISATION, COTISATION, ADHESION, AIDE,
* TRANSACTION_WAVE). * TRANSACTION_WAVE).
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2026-02-21 * @since 2026-02-21
*/ */
@Entity @Entity
@Table(name = "pieces_jointes", indexes = { @Table(name = "pieces_jointes", indexes = {
@Index(name = "idx_pj_document", columnList = "document_id"), @Index(name = "idx_pj_document", columnList = "document_id"),
@Index(name = "idx_pj_entite", columnList = "type_entite_rattachee," @Index(name = "idx_pj_entite", columnList = "type_entite_rattachee,"
+ " entite_rattachee_id"), + " entite_rattachee_id"),
@Index(name = "idx_pj_type_entite", columnList = "type_entite_rattachee") @Index(name = "idx_pj_type_entite", columnList = "type_entite_rattachee")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class PieceJointe extends BaseEntity { public class PieceJointe extends BaseEntity {
/** Ordre d'affichage. */ /** Ordre d'affichage. */
@NotNull @NotNull
@Min(value = 1, message = "L'ordre doit être positif") @Min(value = 1, message = "L'ordre doit être positif")
@Column(name = "ordre", nullable = false) @Column(name = "ordre", nullable = false)
private Integer ordre; private Integer ordre;
/** Libellé de la pièce jointe. */ /** Libellé de la pièce jointe. */
@Size(max = 200) @Size(max = 200)
@Column(name = "libelle", length = 200) @Column(name = "libelle", length = 200)
private String libelle; private String libelle;
/** Commentaire. */ /** Commentaire. */
@Size(max = 500) @Size(max = 500)
@Column(name = "commentaire", length = 500) @Column(name = "commentaire", length = 500)
private String commentaire; private String commentaire;
/** Document associé (obligatoire). */ /** Document associé (obligatoire). */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "document_id", nullable = false) @JoinColumn(name = "document_id", nullable = false)
private Document document; private Document document;
/** /**
* Type de l'entité rattachée (code du domaine * Type de l'entité rattachée (code du domaine
* {@code ENTITE_RATTACHEE} dans * {@code ENTITE_RATTACHEE} dans
* {@code types_reference}). * {@code types_reference}).
* *
* <p> * <p>
* Valeurs attendues : {@code MEMBRE}, * Valeurs attendues : {@code MEMBRE},
* {@code ORGANISATION}, {@code COTISATION}, * {@code ORGANISATION}, {@code COTISATION},
* {@code ADHESION}, {@code AIDE}, * {@code ADHESION}, {@code AIDE},
* {@code TRANSACTION_WAVE}. * {@code TRANSACTION_WAVE}.
*/ */
@NotBlank @NotBlank
@Size(max = 50) @Size(max = 50)
@Column(name = "type_entite_rattachee", nullable = false, length = 50) @Column(name = "type_entite_rattachee", nullable = false, length = 50)
private String typeEntiteRattachee; private String typeEntiteRattachee;
/** /**
* UUID de l'entité rattachée (membre, * UUID de l'entité rattachée (membre,
* organisation, cotisation, etc.). * organisation, cotisation, etc.).
*/ */
@NotNull @NotNull
@Column(name = "entite_rattachee_id", nullable = false) @Column(name = "entite_rattachee_id", nullable = false)
private UUID entiteRattacheeId; private UUID entiteRattacheeId;
/** /**
* Callback JPA avant la persistance. * Callback JPA avant la persistance.
* *
* <p> * <p>
* Initialise {@code ordre} à 1 si non * Initialise {@code ordre} à 1 si non
* renseigné. Normalise le type en majuscules. * renseigné. Normalise le type en majuscules.
*/ */
@Override @Override
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (ordre == null) { if (ordre == null) {
ordre = 1; ordre = 1;
} }
if (typeEntiteRattachee != null) { if (typeEntiteRattachee != null) {
typeEntiteRattachee = typeEntiteRattachee.toUpperCase(); typeEntiteRattachee = typeEntiteRattachee.toUpperCase();
} }
} }
} }

View File

@@ -0,0 +1,144 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.LocalTime;
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;
/**
* Procès-verbal d'AG ou de CA conforme OHADA AUDSCGIE.
*
* <p>Structure obligatoire selon l'Acte Uniforme OHADA Sociétés Coopératives (15 décembre 2010,
* applicable depuis 15 mai 2011) + AUDSCGIE révisé 30 janvier 2014 :
*
* <ul>
* <li>Date, lieu, heures d'ouverture et clôture
* <li>Quorum calculé (selon convoqués / présents / représentés)
* <li>Ordre du jour structuré
* <li>Résolutions votées avec décompte (pour / contre / abstentions / adoptée)
* <li>Signatures président + secrétaire
* <li>Archivage immuable au siège (hash SHA-256 pour intégrité)
* </ul>
*
* @since 2026-04-25 (P1-NEW-2)
*/
@Entity
@Table(name = "proces_verbaux")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ProcesVerbal extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotBlank
@Column(name = "type_seance", nullable = false, length = 20)
private String typeSeance; // AG_CONSTITUTIVE, AG_ORDINAIRE, AG_EXTRAORDINAIRE, CA, BUREAU
@NotBlank
@Column(name = "titre", nullable = false, length = 255)
private String titre;
@Column(name = "numero_seance", length = 50)
private String numeroSeance;
// Convocation
@NotNull
@Column(name = "date_convocation", nullable = false)
private LocalDateTime dateConvocation;
@Column(name = "mode_convocation", length = 50)
private String modeConvocation;
// Tenue
@NotNull
@Column(name = "date_seance", nullable = false)
private LocalDateTime dateSeance;
@Column(name = "lieu", length = 255)
private String lieu;
@Column(name = "heure_ouverture")
private LocalTime heureOuverture;
@Column(name = "heure_cloture")
private LocalTime heureCloture;
// Quorum
@Column(name = "nombre_convoques", nullable = false)
@Builder.Default
private int nombreConvoques = 0;
@Column(name = "nombre_presents", nullable = false)
@Builder.Default
private int nombrePresents = 0;
@Column(name = "nombre_representes", nullable = false)
@Builder.Default
private int nombreRepresentes = 0;
@Column(name = "quorum_atteint", nullable = false)
@Builder.Default
private boolean quorumAtteint = false;
@Column(name = "quorum_requis_pct", precision = 5, scale = 2)
private BigDecimal quorumRequisPct;
@Column(name = "quorum_calcule_pct", precision = 5, scale = 2)
private BigDecimal quorumCalculePct;
// Présidence
@Column(name = "president_seance_id")
private UUID presidentSeanceId;
@Column(name = "secretaire_seance_id")
private UUID secretaireSeanceId;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "scrutateurs_ids", columnDefinition = "jsonb")
private String scrutateursIds; // JSON array of UUIDs
// Contenu
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "ordre_du_jour", columnDefinition = "jsonb", nullable = false)
private String ordreDuJour; // JSON: [{numero, intitule, type}]
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "resolutions", columnDefinition = "jsonb", nullable = false)
private String resolutions; // JSON: [{numero, intitule, votesPour, votesContre, votesAbstention, adoptee}]
@Column(name = "deliberations", columnDefinition = "TEXT")
private String deliberations;
// Signature & archivage
@NotBlank
@Column(name = "statut", nullable = false, length = 30)
@Builder.Default
private String statut = "BROUILLON"; // BROUILLON, ADOPTE, SIGNE, ARCHIVE
@Column(name = "hash_sha256", length = 64)
private String hashSha256;
@Column(name = "date_signature")
private LocalDateTime dateSignature;
@Column(name = "signature_president", length = 500)
private String signaturePresident;
@Column(name = "signature_secretaire", length = 500)
private String signatureSecretaire;
}

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,68 @@
package dev.lions.unionflow.server.entity;
/**
* Référentiel comptable applicable à une {@link Organisation}.
*
* <p>OHADA dispose désormais de plusieurs référentiels selon la nature de l'entité :
*
* <ul>
* <li>{@link #SYSCOHADA} — Système Comptable OHADA révisé (1er jan 2018) pour entités
* commerciales/coopératives à but lucratif.
* <li>{@link #SYCEBNL} — Système Comptable OHADA des Entités à But Non Lucratif (11ᵉ Acte
* uniforme, entré en vigueur <strong>1er jan 2024</strong>) pour mutuelles sociales,
* associations, ONG, fondations, syndicats, projets de développement.
* <li>{@link #PCSFD_UMOA} — Plan Comptable des Systèmes Financiers Décentralisés UMOA pour SFD
* soumis Commission Bancaire UMOA (article 44, encours ≥ 2 milliards FCFA = catégorie III).
* </ul>
*
* <p>Le mapping par défaut depuis {@code Organisation.typeOrganisation} se trouve dans
* {@link #defaultFor(String)}. L'admin peut overrider manuellement (cas hybrides).
*
* <p>Voir : {@code unionflow/docs/COMPLIANCE_OHADA_SYCEBNL.md} et {@code
* unionflow/docs/COMPLIANCE_OHADA_SYSCOHADA.md}.
*
* @since 2026-04-25
*/
public enum ReferentielComptable {
/** Système Comptable OHADA révisé (entités commerciales / coopératives lucratives). */
SYSCOHADA,
/**
* Système Comptable OHADA des Entités à But Non Lucratif (mutuelles sociales, associations,
* ONG, fondations, Lions Clubs, syndicats). Acte uniforme entré en vigueur 1er janvier 2024.
*/
SYCEBNL,
/**
* Plan Comptable des Systèmes Financiers Décentralisés UMOA. Pour SFD article 44 (encours ≥ 2
* Md FCFA = catégorie III, commissaire aux comptes obligatoire agréé OHADA, sélection soumise
* approbation Commission Bancaire UMOA).
*/
PCSFD_UMOA;
/**
* Retourne le référentiel par défaut suggéré pour un {@code typeOrganisation}. L'admin peut
* overrider manuellement à la création/édition d'une organisation.
*
* @param typeOrganisation valeur de {@link Organisation#getTypeOrganisation()}
* @return référentiel par défaut, jamais null (fallback {@link #SYSCOHADA})
*/
public static ReferentielComptable defaultFor(String typeOrganisation) {
if (typeOrganisation == null) {
return SYSCOHADA;
}
return switch (typeOrganisation.toUpperCase()) {
case "MUTUELLE_SANTE",
"ASSOCIATION",
"LIONS_CLUB",
"ONG",
"FONDATION",
"SYNDICAT",
"ORDRE_PROFESSIONNEL",
"PROJET_DEVELOPPEMENT" ->
SYCEBNL;
case "SFD_TIER_1", "SFD_CATEGORIE_III" -> PCSFD_UMOA;
default -> SYSCOHADA;
};
}
}

View File

@@ -1,98 +1,98 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Role pour la gestion des rôles dans le système * Entité Role pour la gestion des rôles dans le système
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.1 * @version 3.1
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table(name = "roles", indexes = { @Table(name = "roles", indexes = {
@Index(name = "idx_role_code", columnList = "code", unique = true), @Index(name = "idx_role_code", columnList = "code", unique = true),
@Index(name = "idx_role_actif", columnList = "actif"), @Index(name = "idx_role_actif", columnList = "actif"),
@Index(name = "idx_role_niveau", columnList = "niveau_hierarchique") @Index(name = "idx_role_niveau", columnList = "niveau_hierarchique")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Role extends BaseEntity { public class Role extends BaseEntity {
/** Code unique du rôle */ /** Code unique du rôle */
@NotBlank @NotBlank
@Column(name = "code", unique = true, nullable = false, length = 50) @Column(name = "code", unique = true, nullable = false, length = 50)
private String code; private String code;
/** Libellé du rôle */ /** Libellé du rôle */
@NotBlank @NotBlank
@Column(name = "libelle", nullable = false, length = 100) @Column(name = "libelle", nullable = false, length = 100)
private String libelle; private String libelle;
/** Description du rôle */ /** Description du rôle */
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
/** Niveau hiérarchique (plus bas = plus prioritaire) */ /** Niveau hiérarchique (plus bas = plus prioritaire) */
@NotNull @NotNull
@Builder.Default @Builder.Default
@Column(name = "niveau_hierarchique", nullable = false) @Column(name = "niveau_hierarchique", nullable = false)
private Integer niveauHierarchique = 100; private Integer niveauHierarchique = 100;
/** Type de rôle (SYSTEME, ORGANISATION, PERSONNALISE) */ /** Type de rôle (SYSTEME, ORGANISATION, PERSONNALISE) */
@Column(name = "type_role", nullable = false, length = 50) @Column(name = "type_role", nullable = false, length = 50)
private String typeRole; private String typeRole;
/** Catégorie du rôle (PLATEFORME, FONCTIONNEL, METIER) */ /** Catégorie du rôle (PLATEFORME, FONCTIONNEL, METIER) */
@Column(name = "categorie", length = 30) @Column(name = "categorie", length = 30)
@Builder.Default @Builder.Default
private String categorie = "FONCTIONNEL"; private String categorie = "FONCTIONNEL";
/** Organisation propriétaire (null pour rôles système) */ /** Organisation propriétaire (null pour rôles système) */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
/** Permissions associées */ /** Permissions associées */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "role", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "role", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<RolePermission> permissions = new ArrayList<>(); private List<RolePermission> permissions = new ArrayList<>();
/** Énumération des constantes de types de rôle */ /** Énumération des constantes de types de rôle */
public enum TypeRole { public enum TypeRole {
SYSTEME, SYSTEME,
ORGANISATION, ORGANISATION,
PERSONNALISE; PERSONNALISE;
} }
/** Méthode métier pour vérifier si c'est un rôle système */ /** Méthode métier pour vérifier si c'est un rôle système */
public boolean isRoleSysteme() { public boolean isRoleSysteme() {
return TypeRole.SYSTEME.name().equals(typeRole); return TypeRole.SYSTEME.name().equals(typeRole);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (typeRole == null) { if (typeRole == null) {
typeRole = TypeRole.PERSONNALISE.name(); typeRole = TypeRole.PERSONNALISE.name();
} }
if (niveauHierarchique == null) { if (niveauHierarchique == null) {
niveauHierarchique = 100; niveauHierarchique = 100;
} }
} }
} }

View File

@@ -0,0 +1,77 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDateTime;
import java.util.UUID;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Délégation temporaire d'un rôle.
*
* <p>Cas d'usage : trésorier en congé délègue son rôle au trésorier adjoint pour 2 semaines.
*
* <p>Le {@code PermissionChecker} consulte cette table pour calculer le rôle effectif :
* <strong>roles directs roles délégués actifs</strong> (statut=ACTIVE et dateFin > now).
*
* @since 2026-04-25 (P1-NEW-5)
*/
@Entity
@Table(name = "role_delegations", indexes = {
@Index(name = "idx_delegation_org", columnList = "organisation_id"),
@Index(name = "idx_delegation_delegataire", columnList = "delegataire_user_id")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class RoleDelegation extends BaseEntity {
@NotNull
@Column(name = "organisation_id", nullable = false)
private UUID organisationId;
@NotNull
@Column(name = "delegant_user_id", nullable = false)
private UUID delegantUserId;
@NotNull
@Column(name = "delegataire_user_id", nullable = false)
private UUID delegataireUserId;
@NotBlank
@Column(name = "role_delegue", nullable = false, length = 50)
private String roleDelegue;
@NotNull
@Column(name = "date_debut", nullable = false)
private LocalDateTime dateDebut;
@NotNull
@Column(name = "date_fin", nullable = false)
private LocalDateTime dateFin;
@Column(name = "motif", length = 500)
private String motif;
@NotBlank
@Column(name = "statut", nullable = false, length = 20)
@Builder.Default
private String statut = "ACTIVE"; // ACTIVE, EXPIREE, REVOQUEE
@Column(name = "date_revocation")
private LocalDateTime dateRevocation;
/** Vrai si la délégation est active à l'instant donné. */
public boolean isActiveAt(LocalDateTime instant) {
return "ACTIVE".equals(statut)
&& dateDebut != null && !dateDebut.isAfter(instant)
&& dateFin != null && dateFin.isAfter(instant);
}
}

View File

@@ -1,54 +1,54 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Table de liaison entre Role et Permission * Table de liaison entre Role et Permission
* Permet à un rôle d'avoir plusieurs permissions * Permet à un rôle d'avoir plusieurs permissions
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "roles_permissions", name = "roles_permissions",
indexes = { indexes = {
@Index(name = "idx_role_permission_role", columnList = "role_id"), @Index(name = "idx_role_permission_role", columnList = "role_id"),
@Index(name = "idx_role_permission_permission", columnList = "permission_id") @Index(name = "idx_role_permission_permission", columnList = "permission_id")
}, },
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint( @UniqueConstraint(
name = "uk_role_permission", name = "uk_role_permission",
columnNames = {"role_id", "permission_id"}) columnNames = {"role_id", "permission_id"})
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class RolePermission extends BaseEntity { public class RolePermission extends BaseEntity {
/** Rôle */ /** Rôle */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_id", nullable = false) @JoinColumn(name = "role_id", nullable = false)
private Role role; private Role role;
/** Permission */ /** Permission */
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "permission_id", nullable = false) @JoinColumn(name = "permission_id", nullable = false)
private Permission permission; private Permission permission;
/** Commentaire sur l'association */ /** Commentaire sur l'association */
@Column(name = "commentaire", length = 500) @Column(name = "commentaire", length = 500)
private String commentaire; private String commentaire;
} }

View File

@@ -1,171 +1,171 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres; import dev.lions.unionflow.server.api.enums.abonnement.PlageMembres;
import dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription; import dev.lions.unionflow.server.api.enums.abonnement.StatutSouscription;
import dev.lions.unionflow.server.api.enums.abonnement.StatutValidationSouscription; import dev.lions.unionflow.server.api.enums.abonnement.StatutValidationSouscription;
import dev.lions.unionflow.server.api.enums.abonnement.TypePeriodeAbonnement; import dev.lions.unionflow.server.api.enums.abonnement.TypePeriodeAbonnement;
import dev.lions.unionflow.server.api.enums.abonnement.TypeOrganisationFacturation; import dev.lions.unionflow.server.api.enums.abonnement.TypeOrganisationFacturation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
import lombok.*; import lombok.*;
/** /**
* Abonnement actif d'une organisation racine à un forfait UnionFlow. * Abonnement actif d'une organisation racine à un forfait UnionFlow.
* *
* <p>Règle clé : quand {@code quotaUtilise >= quotaMax}, toute nouvelle * <p>Règle clé : quand {@code quotaUtilise >= quotaMax}, toute nouvelle
* validation d'adhésion est bloquée avec un message explicite. * validation d'adhésion est bloquée avec un message explicite.
* Le manager peut upgrader son forfait à tout moment. * Le manager peut upgrader son forfait à tout moment.
* *
* <p>Table : {@code souscriptions_organisation} * <p>Table : {@code souscriptions_organisation}
*/ */
@Entity @Entity
@Table( @Table(
name = "souscriptions_organisation", name = "souscriptions_organisation",
indexes = { indexes = {
@Index(name = "idx_souscription_org", columnList = "organisation_id", unique = true), @Index(name = "idx_souscription_org", columnList = "organisation_id", unique = true),
@Index(name = "idx_souscription_statut", columnList = "statut"), @Index(name = "idx_souscription_statut", columnList = "statut"),
@Index(name = "idx_souscription_fin", columnList = "date_fin") @Index(name = "idx_souscription_fin", columnList = "date_fin")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class SouscriptionOrganisation extends BaseEntity { public class SouscriptionOrganisation extends BaseEntity {
/** Organisation racine abonnée (une seule souscription active par org) */ /** Organisation racine abonnée (une seule souscription active par org) */
@NotNull @NotNull
@OneToOne(fetch = FetchType.LAZY) @OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false, unique = true) @JoinColumn(name = "organisation_id", nullable = false, unique = true)
private Organisation organisation; private Organisation organisation;
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "formule_id", nullable = false) @JoinColumn(name = "formule_id", nullable = false)
private FormuleAbonnement formule; private FormuleAbonnement formule;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "type_periode", nullable = false, length = 10) @Column(name = "type_periode", nullable = false, length = 10)
private TypePeriodeAbonnement typePeriode = TypePeriodeAbonnement.MENSUEL; private TypePeriodeAbonnement typePeriode = TypePeriodeAbonnement.MENSUEL;
@NotNull @NotNull
@Column(name = "date_debut", nullable = false) @Column(name = "date_debut", nullable = false)
private LocalDate dateDebut; private LocalDate dateDebut;
@NotNull @NotNull
@Column(name = "date_fin", nullable = false) @Column(name = "date_fin", nullable = false)
private LocalDate dateFin; private LocalDate dateFin;
/** Snapshot du quota max au moment de la souscription */ /** Snapshot du quota max au moment de la souscription */
@Column(name = "quota_max") @Column(name = "quota_max")
private Integer quotaMax; private Integer quotaMax;
/** Compteur incrémenté à chaque adhésion validée */ /** Compteur incrémenté à chaque adhésion validée */
@Builder.Default @Builder.Default
@Min(0) @Min(0)
@Column(name = "quota_utilise", nullable = false) @Column(name = "quota_utilise", nullable = false)
private Integer quotaUtilise = 0; private Integer quotaUtilise = 0;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "statut", nullable = false, length = 30) @Column(name = "statut", nullable = false, length = 30)
private StatutSouscription statut = StatutSouscription.ACTIVE; private StatutSouscription statut = StatutSouscription.ACTIVE;
@Column(name = "reference_paiement_wave", length = 100) @Column(name = "reference_paiement_wave", length = 100)
private String referencePaiementWave; private String referencePaiementWave;
@Column(name = "wave_session_id", length = 255) @Column(name = "wave_session_id", length = 255)
private String waveSessionId; private String waveSessionId;
@Column(name = "wave_checkout_url", length = 1024) @Column(name = "wave_checkout_url", length = 1024)
private String waveCheckoutUrl; private String waveCheckoutUrl;
@Column(name = "date_dernier_paiement") @Column(name = "date_dernier_paiement")
private LocalDate dateDernierPaiement; private LocalDate dateDernierPaiement;
@Column(name = "date_prochain_paiement") @Column(name = "date_prochain_paiement")
private LocalDate dateProchainePaiement; private LocalDate dateProchainePaiement;
// ── Champs workflow de validation (onboarding) ──────────────────────────── // ── Champs workflow de validation (onboarding) ────────────────────────────
/** Plage de membres choisie lors de la souscription. */ /** Plage de membres choisie lors de la souscription. */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "plage", length = 20) @Column(name = "plage", length = 20)
private PlageMembres plage; private PlageMembres plage;
/** Type d'organisation déclaré, utilisé pour le coefficient tarifaire. */ /** Type d'organisation déclaré, utilisé pour le coefficient tarifaire. */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_organisation", length = 30) @Column(name = "type_organisation", length = 30)
private TypeOrganisationFacturation typeOrganisationSouscription; private TypeOrganisationFacturation typeOrganisationSouscription;
/** Coefficient multiplicateur effectivement appliqué (org × période). */ /** Coefficient multiplicateur effectivement appliqué (org × période). */
@Column(name = "coefficient_applique", precision = 4, scale = 2) @Column(name = "coefficient_applique", precision = 4, scale = 2)
private BigDecimal coefficientApplique; private BigDecimal coefficientApplique;
/** État du workflow de validation SuperAdmin. */ /** État du workflow de validation SuperAdmin. */
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "statut_validation", nullable = false, length = 40) @Column(name = "statut_validation", nullable = false, length = 40)
private StatutValidationSouscription statutValidation = StatutValidationSouscription.EN_ATTENTE_PAIEMENT; private StatutValidationSouscription statutValidation = StatutValidationSouscription.EN_ATTENTE_PAIEMENT;
/** Montant total facturé pour la période choisie (en XOF). */ /** Montant total facturé pour la période choisie (en XOF). */
@Column(name = "montant_total", precision = 12, scale = 2) @Column(name = "montant_total", precision = 12, scale = 2)
private BigDecimal montantTotal; private BigDecimal montantTotal;
/** Date à laquelle le SuperAdmin a approuvé ou rejeté la souscription. */ /** Date à laquelle le SuperAdmin a approuvé ou rejeté la souscription. */
@Column(name = "date_validation") @Column(name = "date_validation")
private LocalDate dateValidation; private LocalDate dateValidation;
/** UUID du SuperAdmin ayant validé ou rejeté. */ /** UUID du SuperAdmin ayant validé ou rejeté. */
@Column(name = "validated_by_id") @Column(name = "validated_by_id")
private UUID validatedById; private UUID validatedById;
/** Motif de rejet renseigné par le SuperAdmin. */ /** Motif de rejet renseigné par le SuperAdmin. */
@Column(name = "commentaire_rejet", length = 500) @Column(name = "commentaire_rejet", length = 500)
private String commentaireRejet; private String commentaireRejet;
/** Mot de passe temporaire généré à l'activation du compte. */ /** Mot de passe temporaire généré à l'activation du compte. */
@Column(name = "mot_de_passe_temporaire", length = 100) @Column(name = "mot_de_passe_temporaire", length = 100)
private String motDePasseTemporaire; private String motDePasseTemporaire;
// ── Méthodes métier ──────────────────────────────────────────────────────── // ── Méthodes métier ────────────────────────────────────────────────────────
public boolean isActive() { public boolean isActive() {
return StatutSouscription.ACTIVE.equals(statut) return StatutSouscription.ACTIVE.equals(statut)
&& LocalDate.now().isBefore(dateFin.plusDays(1)); && LocalDate.now().isBefore(dateFin.plusDays(1));
} }
public boolean isQuotaDepasse() { public boolean isQuotaDepasse() {
return quotaMax != null && quotaUtilise >= quotaMax; return quotaMax != null && quotaUtilise >= quotaMax;
} }
public int getPlacesRestantes() { public int getPlacesRestantes() {
if (quotaMax == null) return Integer.MAX_VALUE; if (quotaMax == null) return Integer.MAX_VALUE;
return Math.max(0, quotaMax - quotaUtilise); return Math.max(0, quotaMax - quotaUtilise);
} }
/** Incrémente le quota lors de la validation d'une adhésion */ /** Incrémente le quota lors de la validation d'une adhésion */
public void incrementerQuota() { public void incrementerQuota() {
if (quotaUtilise == null) quotaUtilise = 0; if (quotaUtilise == null) quotaUtilise = 0;
quotaUtilise++; quotaUtilise++;
} }
/** Décrémente le quota lors de la radiation d'un membre */ /** Décrémente le quota lors de la radiation d'un membre */
public void decrementerQuota() { public void decrementerQuota() {
if (quotaUtilise != null && quotaUtilise > 0) quotaUtilise--; if (quotaUtilise != null && quotaUtilise > 0) quotaUtilise--;
} }
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (statut == null) statut = StatutSouscription.ACTIVE; if (statut == null) statut = StatutSouscription.ACTIVE;
if (typePeriode == null) typePeriode = TypePeriodeAbonnement.MENSUEL; if (typePeriode == null) typePeriode = TypePeriodeAbonnement.MENSUEL;
if (quotaUtilise == null) quotaUtilise = 0; if (quotaUtilise == null) quotaUtilise = 0;
if (statutValidation == null) statutValidation = StatutValidationSouscription.EN_ATTENTE_PAIEMENT; if (statutValidation == null) statutValidation = StatutValidationSouscription.EN_ATTENTE_PAIEMENT;
if (formule != null && quotaMax == null) quotaMax = formule.getMaxMembres(); if (formule != null && quotaMax == null) quotaMax = formule.getMaxMembres();
} }
} }

View File

@@ -1,91 +1,91 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
/** /**
* Entité Suggestion pour la gestion des suggestions utilisateur * Entité Suggestion pour la gestion des suggestions utilisateur
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
*/ */
@Entity @Entity
@Table( @Table(
name = "suggestions", name = "suggestions",
indexes = { indexes = {
@Index(name = "idx_suggestion_utilisateur", columnList = "utilisateur_id"), @Index(name = "idx_suggestion_utilisateur", columnList = "utilisateur_id"),
@Index(name = "idx_suggestion_statut", columnList = "statut"), @Index(name = "idx_suggestion_statut", columnList = "statut"),
@Index(name = "idx_suggestion_categorie", columnList = "categorie") @Index(name = "idx_suggestion_categorie", columnList = "categorie")
} }
) )
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Suggestion extends BaseEntity { public class Suggestion extends BaseEntity {
@NotNull @NotNull
@Column(name = "utilisateur_id", nullable = false) @Column(name = "utilisateur_id", nullable = false)
private UUID utilisateurId; private UUID utilisateurId;
@Column(name = "utilisateur_nom", length = 255) @Column(name = "utilisateur_nom", length = 255)
private String utilisateurNom; private String utilisateurNom;
@NotBlank @NotBlank
@Column(name = "titre", nullable = false, length = 255) @Column(name = "titre", nullable = false, length = 255)
private String titre; private String titre;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
@Column(name = "justification", columnDefinition = "TEXT") @Column(name = "justification", columnDefinition = "TEXT")
private String justification; private String justification;
@Column(name = "categorie", length = 50) @Column(name = "categorie", length = 50)
private String categorie; // UI, FEATURE, PERFORMANCE, SECURITE, INTEGRATION, MOBILE, REPORTING private String categorie; // UI, FEATURE, PERFORMANCE, SECURITE, INTEGRATION, MOBILE, REPORTING
@Column(name = "priorite_estimee", length = 50) @Column(name = "priorite_estimee", length = 50)
private String prioriteEstimee; // BASSE, MOYENNE, HAUTE, CRITIQUE private String prioriteEstimee; // BASSE, MOYENNE, HAUTE, CRITIQUE
@Column(name = "statut", length = 50) @Column(name = "statut", length = 50)
@Builder.Default @Builder.Default
private String statut = "NOUVELLE"; // NOUVELLE, EVALUATION, APPROUVEE, DEVELOPPEMENT, IMPLEMENTEE, REJETEE private String statut = "NOUVELLE"; // NOUVELLE, EVALUATION, APPROUVEE, DEVELOPPEMENT, IMPLEMENTEE, REJETEE
@Column(name = "nb_votes") @Column(name = "nb_votes")
@Builder.Default @Builder.Default
private Integer nbVotes = 0; private Integer nbVotes = 0;
@Column(name = "nb_commentaires") @Column(name = "nb_commentaires")
@Builder.Default @Builder.Default
private Integer nbCommentaires = 0; private Integer nbCommentaires = 0;
@Column(name = "nb_vues") @Column(name = "nb_vues")
@Builder.Default @Builder.Default
private Integer nbVues = 0; private Integer nbVues = 0;
@Column(name = "date_soumission") @Column(name = "date_soumission")
private LocalDateTime dateSoumission; private LocalDateTime dateSoumission;
@Column(name = "date_evaluation") @Column(name = "date_evaluation")
private LocalDateTime dateEvaluation; private LocalDateTime dateEvaluation;
@Column(name = "date_implementation") @Column(name = "date_implementation")
private LocalDateTime dateImplementation; private LocalDateTime dateImplementation;
@Column(name = "version_ciblee", length = 50) @Column(name = "version_ciblee", length = 50)
private String versionCiblee; private String versionCiblee;
@Column(name = "mise_a_jour", columnDefinition = "TEXT") @Column(name = "mise_a_jour", columnDefinition = "TEXT")
private String miseAJour; private String miseAJour;
} }

View File

@@ -1,66 +1,66 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
/** /**
* Entité SuggestionVote pour gérer les votes sur les suggestions * Entité SuggestionVote pour gérer les votes sur les suggestions
* *
* <p>Permet d'éviter qu'un utilisateur vote plusieurs fois pour la même suggestion. * <p>Permet d'éviter qu'un utilisateur vote plusieurs fois pour la même suggestion.
* La contrainte d'unicité (suggestion_id, utilisateur_id) est gérée au niveau de la base de données. * La contrainte d'unicité (suggestion_id, utilisateur_id) est gérée au niveau de la base de données.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
*/ */
@Entity @Entity
@Table( @Table(
name = "suggestion_votes", name = "suggestion_votes",
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint( @UniqueConstraint(
name = "uk_suggestion_vote", name = "uk_suggestion_vote",
columnNames = {"suggestion_id", "utilisateur_id"} columnNames = {"suggestion_id", "utilisateur_id"}
) )
}, },
indexes = { indexes = {
@Index(name = "idx_vote_suggestion", columnList = "suggestion_id"), @Index(name = "idx_vote_suggestion", columnList = "suggestion_id"),
@Index(name = "idx_vote_utilisateur", columnList = "utilisateur_id") @Index(name = "idx_vote_utilisateur", columnList = "utilisateur_id")
} }
) )
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class SuggestionVote extends BaseEntity { public class SuggestionVote extends BaseEntity {
@NotNull @NotNull
@Column(name = "suggestion_id", nullable = false) @Column(name = "suggestion_id", nullable = false)
private UUID suggestionId; private UUID suggestionId;
@NotNull @NotNull
@Column(name = "utilisateur_id", nullable = false) @Column(name = "utilisateur_id", nullable = false)
private UUID utilisateurId; private UUID utilisateurId;
@Column(name = "date_vote", nullable = false) @Column(name = "date_vote", nullable = false)
@Builder.Default @Builder.Default
private LocalDateTime dateVote = LocalDateTime.now(); private LocalDateTime dateVote = LocalDateTime.now();
@PrePersist @PrePersist
protected void onPrePersist() { protected void onPrePersist() {
if (dateVote == null) { if (dateVote == null) {
dateVote = LocalDateTime.now(); dateVote = LocalDateTime.now();
} }
if (getDateCreation() == null) { if (getDateCreation() == null) {
setDateCreation(LocalDateTime.now()); setDateCreation(LocalDateTime.now());
} }
} }
} }

View File

@@ -1,119 +1,119 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* Entité pour les alertes système. * Entité pour les alertes système.
* Enregistre les alertes de seuils dépassés, erreurs critiques, etc. * Enregistre les alertes de seuils dépassés, erreurs critiques, etc.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-15 * @since 2026-03-15
*/ */
@Entity @Entity
@Table(name = "system_alerts", indexes = { @Table(name = "system_alerts", indexes = {
@Index(name = "idx_system_alert_timestamp", columnList = "timestamp"), @Index(name = "idx_system_alert_timestamp", columnList = "timestamp"),
@Index(name = "idx_system_alert_level", columnList = "level"), @Index(name = "idx_system_alert_level", columnList = "level"),
@Index(name = "idx_system_alert_acknowledged", columnList = "acknowledged"), @Index(name = "idx_system_alert_acknowledged", columnList = "acknowledged"),
@Index(name = "idx_system_alert_source", columnList = "source") @Index(name = "idx_system_alert_source", columnList = "source")
}) })
@Getter @Getter
@Setter @Setter
public class SystemAlert extends BaseEntity { public class SystemAlert extends BaseEntity {
/** /**
* Niveau de l'alerte (CRITICAL, ERROR, WARNING, INFO) * Niveau de l'alerte (CRITICAL, ERROR, WARNING, INFO)
*/ */
@Column(name = "level", nullable = false, length = 20) @Column(name = "level", nullable = false, length = 20)
private String level; private String level;
/** /**
* Titre court de l'alerte * Titre court de l'alerte
*/ */
@Column(name = "title", nullable = false, length = 255) @Column(name = "title", nullable = false, length = 255)
private String title; private String title;
/** /**
* Message détaillé de l'alerte * Message détaillé de l'alerte
*/ */
@Column(name = "message", nullable = false, length = 1000) @Column(name = "message", nullable = false, length = 1000)
private String message; private String message;
/** /**
* Date/heure de création de l'alerte * Date/heure de création de l'alerte
*/ */
@Column(name = "timestamp", nullable = false) @Column(name = "timestamp", nullable = false)
private LocalDateTime timestamp; private LocalDateTime timestamp;
/** /**
* Alerte acquittée ou non * Alerte acquittée ou non
*/ */
@Column(name = "acknowledged", nullable = false) @Column(name = "acknowledged", nullable = false)
private Boolean acknowledged = false; private Boolean acknowledged = false;
/** /**
* Email de l'utilisateur ayant acquitté l'alerte * Email de l'utilisateur ayant acquitté l'alerte
*/ */
@Column(name = "acknowledged_by", length = 255) @Column(name = "acknowledged_by", length = 255)
private String acknowledgedBy; private String acknowledgedBy;
/** /**
* Date/heure d'acquittement * Date/heure d'acquittement
*/ */
@Column(name = "acknowledged_at") @Column(name = "acknowledged_at")
private LocalDateTime acknowledgedAt; private LocalDateTime acknowledgedAt;
/** /**
* Source de l'alerte (CPU, MEMORY, DISK, DATABASE, etc.) * Source de l'alerte (CPU, MEMORY, DISK, DATABASE, etc.)
*/ */
@Column(name = "source", length = 100) @Column(name = "source", length = 100)
private String source; private String source;
/** /**
* Type d'alerte (THRESHOLD, INFO, ERROR, etc.) * Type d'alerte (THRESHOLD, INFO, ERROR, etc.)
*/ */
@Column(name = "alert_type", length = 50) @Column(name = "alert_type", length = 50)
private String alertType; private String alertType;
/** /**
* Valeur actuelle ayant déclenché l'alerte * Valeur actuelle ayant déclenché l'alerte
*/ */
@Column(name = "current_value") @Column(name = "current_value")
private Double currentValue; private Double currentValue;
/** /**
* Valeur seuil dépassée * Valeur seuil dépassée
*/ */
@Column(name = "threshold_value") @Column(name = "threshold_value")
private Double thresholdValue; private Double thresholdValue;
/** /**
* Unité de mesure (%, MB, GB, ms, etc.) * Unité de mesure (%, MB, GB, ms, etc.)
*/ */
@Column(name = "unit", length = 20) @Column(name = "unit", length = 20)
private String unit; private String unit;
/** /**
* Actions recommandées pour résoudre l'alerte * Actions recommandées pour résoudre l'alerte
*/ */
@Column(name = "recommended_actions", columnDefinition = "TEXT") @Column(name = "recommended_actions", columnDefinition = "TEXT")
private String recommendedActions; private String recommendedActions;
/** /**
* Initialisation automatique du timestamp * Initialisation automatique du timestamp
*/ */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (timestamp == null) { if (timestamp == null) {
timestamp = LocalDateTime.now(); timestamp = LocalDateTime.now();
} }
if (acknowledged == null) { if (acknowledged == null) {
acknowledged = false; acknowledged = false;
} }
} }
} }

View File

@@ -1,98 +1,98 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
* Entité pour les logs techniques du système. * Entité pour les logs techniques du système.
* Enregistre les erreurs, warnings, et événements système. * Enregistre les erreurs, warnings, et événements système.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-15 * @since 2026-03-15
*/ */
@Entity @Entity
@Table(name = "system_logs", indexes = { @Table(name = "system_logs", indexes = {
@Index(name = "idx_system_log_timestamp", columnList = "timestamp"), @Index(name = "idx_system_log_timestamp", columnList = "timestamp"),
@Index(name = "idx_system_log_level", columnList = "level"), @Index(name = "idx_system_log_level", columnList = "level"),
@Index(name = "idx_system_log_source", columnList = "source"), @Index(name = "idx_system_log_source", columnList = "source"),
@Index(name = "idx_system_log_user_id", columnList = "user_id") @Index(name = "idx_system_log_user_id", columnList = "user_id")
}) })
@Getter @Getter
@Setter @Setter
public class SystemLog extends BaseEntity { public class SystemLog extends BaseEntity {
/** /**
* Niveau du log (CRITICAL, ERROR, WARNING, INFO, DEBUG) * Niveau du log (CRITICAL, ERROR, WARNING, INFO, DEBUG)
*/ */
@Column(name = "level", nullable = false, length = 20) @Column(name = "level", nullable = false, length = 20)
private String level; private String level;
/** /**
* Source du log (Database, API, Auth, System, Cache, etc.) * Source du log (Database, API, Auth, System, Cache, etc.)
*/ */
@Column(name = "source", nullable = false, length = 100) @Column(name = "source", nullable = false, length = 100)
private String source; private String source;
/** /**
* Message principal du log * Message principal du log
*/ */
@Column(name = "message", nullable = false, length = 1000) @Column(name = "message", nullable = false, length = 1000)
private String message; private String message;
/** /**
* Détails supplémentaires (stacktrace, contexte, etc.) * Détails supplémentaires (stacktrace, contexte, etc.)
*/ */
@Column(name = "details", columnDefinition = "TEXT") @Column(name = "details", columnDefinition = "TEXT")
private String details; private String details;
/** /**
* Date/heure du log * Date/heure du log
*/ */
@Column(name = "timestamp", nullable = false) @Column(name = "timestamp", nullable = false)
private LocalDateTime timestamp; private LocalDateTime timestamp;
/** /**
* Identifiant de l'utilisateur concerné (optionnel) * Identifiant de l'utilisateur concerné (optionnel)
*/ */
@Column(name = "user_id", length = 255) @Column(name = "user_id", length = 255)
private String userId; private String userId;
/** /**
* Adresse IP de la requête (optionnel) * Adresse IP de la requête (optionnel)
*/ */
@Column(name = "ip_address", length = 45) @Column(name = "ip_address", length = 45)
private String ipAddress; private String ipAddress;
/** /**
* Identifiant de session (optionnel) * Identifiant de session (optionnel)
*/ */
@Column(name = "session_id", length = 255) @Column(name = "session_id", length = 255)
private String sessionId; private String sessionId;
/** /**
* Endpoint HTTP concerné (optionnel) * Endpoint HTTP concerné (optionnel)
*/ */
@Column(name = "endpoint", length = 500) @Column(name = "endpoint", length = 500)
private String endpoint; private String endpoint;
/** /**
* Code de statut HTTP (optionnel) * Code de statut HTTP (optionnel)
*/ */
@Column(name = "http_status_code") @Column(name = "http_status_code")
private Integer httpStatusCode; private Integer httpStatusCode;
/** /**
* Initialisation automatique du timestamp * Initialisation automatique du timestamp
*/ */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); // Appel du @PrePersist de BaseEntity (dateCreation, actif) super.onCreate(); // Appel du @PrePersist de BaseEntity (dateCreation, actif)
if (timestamp == null) { if (timestamp == null) {
timestamp = LocalDateTime.now(); timestamp = LocalDateTime.now();
} }
} }
} }

View File

@@ -0,0 +1,62 @@
package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.DecimalMin;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* Taux de change quotidien entre deux {@link Devise}s.
*
* <p>Source : BCEAO (officiel UEMOA), ECB, Fixer.io ou import manuel. Conservation
* historique pour audit et conversions rétroactives.
*
* @since 2026-04-25 (P2-NEW-7)
*/
@Entity
@Table(name = "taux_change",
uniqueConstraints = @UniqueConstraint(
name = "uq_taux_change_paire_date",
columnNames = {"devise_source", "devise_cible", "date_validite"}),
indexes = {
@Index(name = "idx_taux_change_paire", columnList = "devise_source,devise_cible"),
@Index(name = "idx_taux_change_date_validite", columnList = "date_validite")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class TauxChange extends BaseEntity {
@Enumerated(EnumType.STRING)
@Column(name = "devise_source", nullable = false, length = 3)
private Devise deviseSource;
@Enumerated(EnumType.STRING)
@Column(name = "devise_cible", nullable = false, length = 3)
private Devise deviseCible;
/** 1 unité de {@code deviseSource} = {@code taux} unités de {@code deviseCible}. */
@DecimalMin(value = "0.00000001", inclusive = false)
@Column(name = "taux", nullable = false, precision = 18, scale = 8)
private BigDecimal taux;
@Column(name = "date_validite", nullable = false)
private LocalDate dateValidite;
@Builder.Default
@Column(name = "source", nullable = false, length = 50)
private String source = "BCEAO";
}

View File

@@ -1,83 +1,83 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité TemplateNotification pour les templates de notifications réutilisables * Entité TemplateNotification pour les templates de notifications réutilisables
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "templates_notifications", name = "templates_notifications",
indexes = { indexes = {
@Index(name = "idx_template_code", columnList = "code", unique = true), @Index(name = "idx_template_code", columnList = "code", unique = true),
@Index(name = "idx_template_actif", columnList = "actif") @Index(name = "idx_template_actif", columnList = "actif")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class TemplateNotification extends BaseEntity { public class TemplateNotification extends BaseEntity {
/** Code unique du template */ /** Code unique du template */
@NotBlank @NotBlank
@Column(name = "code", unique = true, nullable = false, length = 100) @Column(name = "code", unique = true, nullable = false, length = 100)
private String code; private String code;
/** Sujet du template */ /** Sujet du template */
@Column(name = "sujet", length = 500) @Column(name = "sujet", length = 500)
private String sujet; private String sujet;
/** Corps du template (texte) */ /** Corps du template (texte) */
@Column(name = "corps_texte", columnDefinition = "TEXT") @Column(name = "corps_texte", columnDefinition = "TEXT")
private String corpsTexte; private String corpsTexte;
/** Corps du template (HTML) */ /** Corps du template (HTML) */
@Column(name = "corps_html", columnDefinition = "TEXT") @Column(name = "corps_html", columnDefinition = "TEXT")
private String corpsHtml; private String corpsHtml;
/** Variables disponibles (JSON) */ /** Variables disponibles (JSON) */
@Column(name = "variables_disponibles", columnDefinition = "TEXT") @Column(name = "variables_disponibles", columnDefinition = "TEXT")
private String variablesDisponibles; private String variablesDisponibles;
/** Canaux supportés (JSON array) */ /** Canaux supportés (JSON array) */
@Column(name = "canaux_supportes", length = 500) @Column(name = "canaux_supportes", length = 500)
private String canauxSupportes; private String canauxSupportes;
/** Langue du template */ /** Langue du template */
@Column(name = "langue", length = 10) @Column(name = "langue", length = 10)
private String langue; private String langue;
/** Description */ /** Description */
@Column(name = "description", length = 1000) @Column(name = "description", length = 1000)
private String description; private String description;
/** Notifications utilisant ce template */ /** Notifications utilisant ce template */
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "template", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "template", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<Notification> notifications = new ArrayList<>(); private List<Notification> notifications = new ArrayList<>();
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (langue == null || langue.isEmpty()) { if (langue == null || langue.isEmpty()) {
langue = "fr"; langue = "fr";
} }
} }
} }

View File

@@ -1,92 +1,92 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.UUID; import java.util.UUID;
/** /**
* Entité Ticket pour la gestion des tickets support * Entité Ticket pour la gestion des tickets support
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
*/ */
@Entity @Entity
@Table( @Table(
name = "tickets", name = "tickets",
indexes = { indexes = {
@Index(name = "idx_ticket_utilisateur", columnList = "utilisateur_id"), @Index(name = "idx_ticket_utilisateur", columnList = "utilisateur_id"),
@Index(name = "idx_ticket_statut", columnList = "statut"), @Index(name = "idx_ticket_statut", columnList = "statut"),
@Index(name = "idx_ticket_categorie", columnList = "categorie"), @Index(name = "idx_ticket_categorie", columnList = "categorie"),
@Index(name = "idx_ticket_numero", columnList = "numero_ticket", unique = true) @Index(name = "idx_ticket_numero", columnList = "numero_ticket", unique = true)
} }
) )
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Ticket extends BaseEntity { public class Ticket extends BaseEntity {
@NotBlank @NotBlank
@Column(name = "numero_ticket", nullable = false, unique = true, length = 50) @Column(name = "numero_ticket", nullable = false, unique = true, length = 50)
private String numeroTicket; private String numeroTicket;
@NotNull @NotNull
@Column(name = "utilisateur_id", nullable = false) @Column(name = "utilisateur_id", nullable = false)
private UUID utilisateurId; private UUID utilisateurId;
@NotBlank @NotBlank
@Column(name = "sujet", nullable = false, length = 255) @Column(name = "sujet", nullable = false, length = 255)
private String sujet; private String sujet;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
@Column(name = "categorie", length = 50) @Column(name = "categorie", length = 50)
private String categorie; // TECHNIQUE, FONCTIONNALITE, UTILISATION, COMPTE, AUTRE private String categorie; // TECHNIQUE, FONCTIONNALITE, UTILISATION, COMPTE, AUTRE
@Column(name = "priorite", length = 50) @Column(name = "priorite", length = 50)
private String priorite; // BASSE, NORMALE, HAUTE, URGENTE private String priorite; // BASSE, NORMALE, HAUTE, URGENTE
@Column(name = "statut", length = 50) @Column(name = "statut", length = 50)
@Builder.Default @Builder.Default
private String statut = "OUVERT"; // OUVERT, EN_COURS, EN_ATTENTE, RESOLU, FERME private String statut = "OUVERT"; // OUVERT, EN_COURS, EN_ATTENTE, RESOLU, FERME
@Column(name = "agent_id") @Column(name = "agent_id")
private UUID agentId; private UUID agentId;
@Column(name = "agent_nom", length = 255) @Column(name = "agent_nom", length = 255)
private String agentNom; private String agentNom;
@Column(name = "date_derniere_reponse") @Column(name = "date_derniere_reponse")
private LocalDateTime dateDerniereReponse; private LocalDateTime dateDerniereReponse;
@Column(name = "date_resolution") @Column(name = "date_resolution")
private LocalDateTime dateResolution; private LocalDateTime dateResolution;
@Column(name = "date_fermeture") @Column(name = "date_fermeture")
private LocalDateTime dateFermeture; private LocalDateTime dateFermeture;
@Column(name = "nb_messages") @Column(name = "nb_messages")
@Builder.Default @Builder.Default
private Integer nbMessages = 0; private Integer nbMessages = 0;
@Column(name = "nb_fichiers") @Column(name = "nb_fichiers")
@Builder.Default @Builder.Default
private Integer nbFichiers = 0; private Integer nbFichiers = 0;
@Column(name = "note_satisfaction") @Column(name = "note_satisfaction")
private Integer noteSatisfaction; private Integer noteSatisfaction;
@Column(name = "resolution", columnDefinition = "TEXT") @Column(name = "resolution", columnDefinition = "TEXT")
private String resolution; private String resolution;
} }

View File

@@ -1,183 +1,183 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité Approbation de Transaction * Entité Approbation de Transaction
* *
* Représente une approbation dans le workflow financier multi-niveaux. * Représente une approbation dans le workflow financier multi-niveaux.
* Chaque transaction financière au-dessus d'un certain seuil nécessite une ou plusieurs approbations. * Chaque transaction financière au-dessus d'un certain seuil nécessite une ou plusieurs approbations.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-13 * @since 2026-03-13
*/ */
@Entity @Entity
@Table(name = "transaction_approvals", indexes = { @Table(name = "transaction_approvals", indexes = {
@Index(name = "idx_approval_transaction", columnList = "transaction_id"), @Index(name = "idx_approval_transaction", columnList = "transaction_id"),
@Index(name = "idx_approval_status", columnList = "status"), @Index(name = "idx_approval_status", columnList = "status"),
@Index(name = "idx_approval_requester", columnList = "requester_id"), @Index(name = "idx_approval_requester", columnList = "requester_id"),
@Index(name = "idx_approval_organisation", columnList = "organisation_id"), @Index(name = "idx_approval_organisation", columnList = "organisation_id"),
@Index(name = "idx_approval_created", columnList = "created_at"), @Index(name = "idx_approval_created", columnList = "created_at"),
@Index(name = "idx_approval_level", columnList = "required_level") @Index(name = "idx_approval_level", columnList = "required_level")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class TransactionApproval extends BaseEntity { public class TransactionApproval extends BaseEntity {
/** ID de la transaction financière à approuver */ /** ID de la transaction financière à approuver */
@NotNull @NotNull
@Column(name = "transaction_id", nullable = false) @Column(name = "transaction_id", nullable = false)
private UUID transactionId; private UUID transactionId;
/** Type de transaction (CONTRIBUTION, DEPOSIT, WITHDRAWAL, TRANSFER, SOLIDARITY, EVENT, OTHER) */ /** Type de transaction (CONTRIBUTION, DEPOSIT, WITHDRAWAL, TRANSFER, SOLIDARITY, EVENT, OTHER) */
@NotBlank @NotBlank
@Pattern(regexp = "^(CONTRIBUTION|DEPOSIT|WITHDRAWAL|TRANSFER|SOLIDARITY|EVENT|OTHER)$") @Pattern(regexp = "^(CONTRIBUTION|DEPOSIT|WITHDRAWAL|TRANSFER|SOLIDARITY|EVENT|OTHER)$")
@Column(name = "transaction_type", nullable = false, length = 20) @Column(name = "transaction_type", nullable = false, length = 20)
private String transactionType; private String transactionType;
/** Montant de la transaction */ /** Montant de la transaction */
@NotNull @NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif") @DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "amount", nullable = false, precision = 14, scale = 2) @Column(name = "amount", nullable = false, precision = 14, scale = 2)
private BigDecimal amount; private BigDecimal amount;
/** Code devise ISO 3 lettres */ /** Code devise ISO 3 lettres */
@NotBlank @NotBlank
@Pattern(regexp = "^[A-Z]{3}$") @Pattern(regexp = "^[A-Z]{3}$")
@Builder.Default @Builder.Default
@Column(name = "currency", nullable = false, length = 3) @Column(name = "currency", nullable = false, length = 3)
private String currency = "XOF"; private String currency = "XOF";
/** ID du membre demandeur */ /** ID du membre demandeur */
@NotNull @NotNull
@Column(name = "requester_id", nullable = false) @Column(name = "requester_id", nullable = false)
private UUID requesterId; private UUID requesterId;
/** Nom complet du demandeur (cache pour performance) */ /** Nom complet du demandeur (cache pour performance) */
@NotBlank @NotBlank
@Column(name = "requester_name", nullable = false, length = 200) @Column(name = "requester_name", nullable = false, length = 200)
private String requesterName; private String requesterName;
/** Organisation concernée (peut être null pour transactions globales) */ /** Organisation concernée (peut être null pour transactions globales) */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
/** Niveau d'approbation requis (NONE, LEVEL1, LEVEL2, LEVEL3) */ /** Niveau d'approbation requis (NONE, LEVEL1, LEVEL2, LEVEL3) */
@NotBlank @NotBlank
@Pattern(regexp = "^(NONE|LEVEL1|LEVEL2|LEVEL3)$") @Pattern(regexp = "^(NONE|LEVEL1|LEVEL2|LEVEL3)$")
@Column(name = "required_level", nullable = false, length = 10) @Column(name = "required_level", nullable = false, length = 10)
private String requiredLevel; private String requiredLevel;
/** Statut de l'approbation (PENDING, APPROVED, VALIDATED, REJECTED, EXPIRED, CANCELLED) */ /** Statut de l'approbation (PENDING, APPROVED, VALIDATED, REJECTED, EXPIRED, CANCELLED) */
@NotBlank @NotBlank
@Pattern(regexp = "^(PENDING|APPROVED|VALIDATED|REJECTED|EXPIRED|CANCELLED)$") @Pattern(regexp = "^(PENDING|APPROVED|VALIDATED|REJECTED|EXPIRED|CANCELLED)$")
@Builder.Default @Builder.Default
@Column(name = "status", nullable = false, length = 20) @Column(name = "status", nullable = false, length = 20)
private String status = "PENDING"; private String status = "PENDING";
/** Liste des actions d'approbateurs */ /** Liste des actions d'approbateurs */
@OneToMany(mappedBy = "approval", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @OneToMany(mappedBy = "approval", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<ApproverAction> approvers = new ArrayList<>(); private List<ApproverAction> approvers = new ArrayList<>();
/** Raison du rejet (si status = REJECTED) */ /** Raison du rejet (si status = REJECTED) */
@Size(max = 1000) @Size(max = 1000)
@Column(name = "rejection_reason", length = 1000) @Column(name = "rejection_reason", length = 1000)
private String rejectionReason; private String rejectionReason;
/** Date de création de la demande d'approbation */ /** Date de création de la demande d'approbation */
@NotNull @NotNull
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private LocalDateTime createdAt; private LocalDateTime createdAt;
/** Date d'expiration (timeout) */ /** Date d'expiration (timeout) */
@Column(name = "expires_at") @Column(name = "expires_at")
private LocalDateTime expiresAt; private LocalDateTime expiresAt;
/** Date de completion (approbation finale ou rejet) */ /** Date de completion (approbation finale ou rejet) */
@Column(name = "completed_at") @Column(name = "completed_at")
private LocalDateTime completedAt; private LocalDateTime completedAt;
/** Métadonnées additionnelles (JSON) */ /** Métadonnées additionnelles (JSON) */
@Column(name = "metadata", columnDefinition = "TEXT") @Column(name = "metadata", columnDefinition = "TEXT")
private String metadata; private String metadata;
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (createdAt == null) { if (createdAt == null) {
createdAt = LocalDateTime.now(); createdAt = LocalDateTime.now();
} }
if (currency == null) { if (currency == null) {
currency = "XOF"; currency = "XOF";
} }
if (status == null) { if (status == null) {
status = "PENDING"; status = "PENDING";
} }
// Expiration par défaut: 7 jours // Expiration par défaut: 7 jours
if (expiresAt == null) { if (expiresAt == null) {
expiresAt = createdAt.plusDays(7); expiresAt = createdAt.plusDays(7);
} }
} }
/** Méthode métier pour ajouter une action d'approbateur */ /** Méthode métier pour ajouter une action d'approbateur */
public void addApproverAction(ApproverAction action) { public void addApproverAction(ApproverAction action) {
approvers.add(action); approvers.add(action);
action.setApproval(this); action.setApproval(this);
} }
/** Méthode métier pour compter les approbations */ /** Méthode métier pour compter les approbations */
public long countApprovals() { public long countApprovals() {
return approvers.stream() return approvers.stream()
.filter(a -> "APPROVED".equals(a.getDecision())) .filter(a -> "APPROVED".equals(a.getDecision()))
.count(); .count();
} }
/** Méthode métier pour obtenir le nombre d'approbations requises */ /** Méthode métier pour obtenir le nombre d'approbations requises */
public int getRequiredApprovals() { public int getRequiredApprovals() {
return switch (requiredLevel) { return switch (requiredLevel) {
case "NONE" -> 0; case "NONE" -> 0;
case "LEVEL1" -> 1; case "LEVEL1" -> 1;
case "LEVEL2" -> 2; case "LEVEL2" -> 2;
case "LEVEL3" -> 3; case "LEVEL3" -> 3;
default -> 0; default -> 0;
}; };
} }
/** Méthode métier pour vérifier si toutes les approbations sont reçues */ /** Méthode métier pour vérifier si toutes les approbations sont reçues */
public boolean hasAllApprovals() { public boolean hasAllApprovals() {
return countApprovals() >= getRequiredApprovals(); return countApprovals() >= getRequiredApprovals();
} }
/** Méthode métier pour vérifier si l'approbation est expirée */ /** Méthode métier pour vérifier si l'approbation est expirée */
public boolean isExpired() { public boolean isExpired() {
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt); return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
} }
/** Méthode métier pour vérifier si l'approbation est en attente */ /** Méthode métier pour vérifier si l'approbation est en attente */
public boolean isPending() { public boolean isPending() {
return "PENDING".equals(status); return "PENDING".equals(status);
} }
/** Méthode métier pour vérifier si l'approbation est complétée */ /** Méthode métier pour vérifier si l'approbation est complétée */
public boolean isCompleted() { public boolean isCompleted() {
return "VALIDATED".equals(status) || "REJECTED".equals(status) || "CANCELLED".equals(status); return "VALIDATED".equals(status) || "REJECTED".equals(status) || "CANCELLED".equals(status);
} }
} }

View File

@@ -1,164 +1,164 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave; import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave;
import dev.lions.unionflow.server.api.enums.wave.TypeTransactionWave; import dev.lions.unionflow.server.api.enums.wave.TypeTransactionWave;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité TransactionWave pour le suivi des transactions Wave Mobile Money * Entité TransactionWave pour le suivi des transactions Wave Mobile Money
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table( @Table(
name = "transactions_wave", name = "transactions_wave",
indexes = { indexes = {
@Index(name = "idx_transaction_wave_id", columnList = "wave_transaction_id", unique = true), @Index(name = "idx_transaction_wave_id", columnList = "wave_transaction_id", unique = true),
@Index(name = "idx_transaction_wave_request_id", columnList = "wave_request_id"), @Index(name = "idx_transaction_wave_request_id", columnList = "wave_request_id"),
@Index(name = "idx_transaction_wave_reference", columnList = "wave_reference"), @Index(name = "idx_transaction_wave_reference", columnList = "wave_reference"),
@Index(name = "idx_transaction_wave_statut", columnList = "statut_transaction"), @Index(name = "idx_transaction_wave_statut", columnList = "statut_transaction"),
@Index(name = "idx_transaction_wave_compte", columnList = "compte_wave_id") @Index(name = "idx_transaction_wave_compte", columnList = "compte_wave_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class TransactionWave extends BaseEntity { public class TransactionWave extends BaseEntity {
/** Identifiant Wave de la transaction (unique) */ /** Identifiant Wave de la transaction (unique) */
@NotBlank @NotBlank
@Column(name = "wave_transaction_id", unique = true, nullable = false, length = 100) @Column(name = "wave_transaction_id", unique = true, nullable = false, length = 100)
private String waveTransactionId; private String waveTransactionId;
/** Identifiant de requête Wave */ /** Identifiant de requête Wave */
@Column(name = "wave_request_id", length = 100) @Column(name = "wave_request_id", length = 100)
private String waveRequestId; private String waveRequestId;
/** Référence Wave */ /** Référence Wave */
@Column(name = "wave_reference", length = 100) @Column(name = "wave_reference", length = 100)
private String waveReference; private String waveReference;
/** Type de transaction */ /** Type de transaction */
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_transaction", nullable = false, length = 50) @Column(name = "type_transaction", nullable = false, length = 50)
private TypeTransactionWave typeTransaction; private TypeTransactionWave typeTransaction;
/** Statut de la transaction */ /** Statut de la transaction */
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "statut_transaction", nullable = false, length = 30) @Column(name = "statut_transaction", nullable = false, length = 30)
private StatutTransactionWave statutTransaction = StatutTransactionWave.INITIALISE; private StatutTransactionWave statutTransaction = StatutTransactionWave.INITIALISE;
/** Montant de la transaction */ /** Montant de la transaction */
@NotNull @NotNull
@DecimalMin(value = "0.0", message = "Le montant doit être positif") @DecimalMin(value = "0.0", message = "Le montant doit être positif")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "montant", nullable = false, precision = 14, scale = 2) @Column(name = "montant", nullable = false, precision = 14, scale = 2)
private BigDecimal montant; private BigDecimal montant;
/** Frais de transaction */ /** Frais de transaction */
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 10, fraction = 2) @Digits(integer = 10, fraction = 2)
@Column(name = "frais", precision = 12, scale = 2) @Column(name = "frais", precision = 12, scale = 2)
private BigDecimal frais; private BigDecimal frais;
/** Montant net (montant - frais) */ /** Montant net (montant - frais) */
@DecimalMin(value = "0.0") @DecimalMin(value = "0.0")
@Digits(integer = 12, fraction = 2) @Digits(integer = 12, fraction = 2)
@Column(name = "montant_net", precision = 14, scale = 2) @Column(name = "montant_net", precision = 14, scale = 2)
private BigDecimal montantNet; private BigDecimal montantNet;
/** Code devise */ /** Code devise */
@NotBlank @NotBlank
@Pattern(regexp = "^[A-Z]{3}$") @Pattern(regexp = "^[A-Z]{3}$")
@Column(name = "code_devise", nullable = false, length = 3) @Column(name = "code_devise", nullable = false, length = 3)
private String codeDevise; private String codeDevise;
/** Numéro téléphone payeur */ /** Numéro téléphone payeur */
@Column(name = "telephone_payeur", length = 13) @Column(name = "telephone_payeur", length = 13)
private String telephonePayeur; private String telephonePayeur;
/** Numéro téléphone bénéficiaire */ /** Numéro téléphone bénéficiaire */
@Column(name = "telephone_beneficiaire", length = 13) @Column(name = "telephone_beneficiaire", length = 13)
private String telephoneBeneficiaire; private String telephoneBeneficiaire;
/** Métadonnées JSON (réponse complète de Wave API) */ /** Métadonnées JSON (réponse complète de Wave API) */
@Column(name = "metadonnees", columnDefinition = "TEXT") @Column(name = "metadonnees", columnDefinition = "TEXT")
private String metadonnees; private String metadonnees;
/** Réponse complète de Wave API (JSON) */ /** Réponse complète de Wave API (JSON) */
@Column(name = "reponse_wave_api", columnDefinition = "TEXT") @Column(name = "reponse_wave_api", columnDefinition = "TEXT")
private String reponseWaveApi; private String reponseWaveApi;
/** Nombre de tentatives */ /** Nombre de tentatives */
@Builder.Default @Builder.Default
@Column(name = "nombre_tentatives", nullable = false) @Column(name = "nombre_tentatives", nullable = false)
private Integer nombreTentatives = 0; private Integer nombreTentatives = 0;
/** Date de dernière tentative */ /** Date de dernière tentative */
@Column(name = "date_derniere_tentative") @Column(name = "date_derniere_tentative")
private LocalDateTime dateDerniereTentative; private LocalDateTime dateDerniereTentative;
/** Message d'erreur (si échec) */ /** Message d'erreur (si échec) */
@Column(name = "message_erreur", length = 1000) @Column(name = "message_erreur", length = 1000)
private String messageErreur; private String messageErreur;
// Relations // Relations
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "compte_wave_id", nullable = false) @JoinColumn(name = "compte_wave_id", nullable = false)
private CompteWave compteWave; private CompteWave compteWave;
@JsonIgnore @JsonIgnore
@OneToMany(mappedBy = "transactionWave", cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(mappedBy = "transactionWave", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@Builder.Default @Builder.Default
private List<WebhookWave> webhooks = new ArrayList<>(); private List<WebhookWave> webhooks = new ArrayList<>();
/** Méthode métier pour vérifier si la transaction est réussie */ /** Méthode métier pour vérifier si la transaction est réussie */
public boolean isReussie() { public boolean isReussie() {
return StatutTransactionWave.REUSSIE.equals(statutTransaction); return StatutTransactionWave.REUSSIE.equals(statutTransaction);
} }
/** Méthode métier pour vérifier si la transaction peut être retentée */ /** Méthode métier pour vérifier si la transaction peut être retentée */
public boolean peutEtreRetentee() { public boolean peutEtreRetentee() {
return (statutTransaction == StatutTransactionWave.ECHOUE return (statutTransaction == StatutTransactionWave.ECHOUE
|| statutTransaction == StatutTransactionWave.EXPIRED) || statutTransaction == StatutTransactionWave.EXPIRED)
&& (nombreTentatives == null || nombreTentatives < 5); && (nombreTentatives == null || nombreTentatives < 5);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (statutTransaction == null) { if (statutTransaction == null) {
statutTransaction = StatutTransactionWave.INITIALISE; statutTransaction = StatutTransactionWave.INITIALISE;
} }
if (codeDevise == null || codeDevise.isEmpty()) { if (codeDevise == null || codeDevise.isEmpty()) {
codeDevise = "XOF"; codeDevise = "XOF";
} }
if (nombreTentatives == null) { if (nombreTentatives == null) {
nombreTentatives = 0; nombreTentatives = 0;
} }
if (montantNet == null && montant != null && frais != null) { if (montantNet == null && montant != null && frais != null) {
montantNet = montant.subtract(frais); montantNet = montant.subtract(frais);
} }
} }
} }

View File

@@ -1,206 +1,206 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.FetchType; import jakarta.persistence.FetchType;
import jakarta.persistence.Index; import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint; import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Donnée de référence paramétrable via le client. * Donnée de référence paramétrable via le client.
* *
* <p> * <p>
* Remplace toutes les enums Java et valeurs hardcodées * Remplace toutes les enums Java et valeurs hardcodées
* par une table unique CRUD-able depuis l'interface * par une table unique CRUD-able depuis l'interface
* d'administration. Chaque ligne appartient à un * d'administration. Chaque ligne appartient à un
* {@code domaine} (ex: STATUT_ORGANISATION, DEVISE) * {@code domaine} (ex: STATUT_ORGANISATION, DEVISE)
* et porte un {@code code} unique dans ce domaine. * et porte un {@code code} unique dans ce domaine.
* *
* <p> * <p>
* Le champ {@code organisation} permet une * Le champ {@code organisation} permet une
* personnalisation par organisation. Lorsqu'il est * personnalisation par organisation. Lorsqu'il est
* {@code null}, la valeur est globale à la plateforme. * {@code null}, la valeur est globale à la plateforme.
* *
* <p> * <p>
* Table : {@code types_reference} * Table : {@code types_reference}
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2026-02-21 * @since 2026-02-21
*/ */
@Entity @Entity
@Table(name = "types_reference", indexes = { @Table(name = "types_reference", indexes = {
@Index(name = "idx_typeref_domaine", columnList = "domaine"), @Index(name = "idx_typeref_domaine", columnList = "domaine"),
@Index(name = "idx_typeref_domaine_actif", columnList = "domaine, actif, ordre_affichage"), @Index(name = "idx_typeref_domaine_actif", columnList = "domaine, actif, ordre_affichage"),
@Index(name = "idx_typeref_org", columnList = "organisation_id") @Index(name = "idx_typeref_org", columnList = "organisation_id")
}, uniqueConstraints = { }, uniqueConstraints = {
@UniqueConstraint(name = "uk_typeref_domaine_code_org", columnNames = { @UniqueConstraint(name = "uk_typeref_domaine_code_org", columnNames = {
"domaine", "code", "organisation_id" "domaine", "code", "organisation_id"
}) })
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class TypeReference extends BaseEntity { public class TypeReference extends BaseEntity {
/** /**
* Domaine fonctionnel de cette valeur de référence. * Domaine fonctionnel de cette valeur de référence.
* *
* <p> * <p>
* Exemples : {@code STATUT_ORGANISATION}, * Exemples : {@code STATUT_ORGANISATION},
* {@code TYPE_ORGANISATION}, {@code DEVISE}. * {@code TYPE_ORGANISATION}, {@code DEVISE}.
*/ */
@NotBlank @NotBlank
@Size(max = 50) @Size(max = 50)
@Column(name = "domaine", nullable = false, length = 50) @Column(name = "domaine", nullable = false, length = 50)
private String domaine; private String domaine;
/** /**
* Code technique unique au sein du domaine. * Code technique unique au sein du domaine.
* *
* <p> * <p>
* Exemples : {@code ACTIVE}, {@code XOF}, * Exemples : {@code ACTIVE}, {@code XOF},
* {@code ASSOCIATION}. * {@code ASSOCIATION}.
*/ */
@NotBlank @NotBlank
@Size(max = 50) @Size(max = 50)
@Column(name = "code", nullable = false, length = 50) @Column(name = "code", nullable = false, length = 50)
private String code; private String code;
/** /**
* Libellé affiché dans l'interface utilisateur. * Libellé affiché dans l'interface utilisateur.
* *
* <p> * <p>
* Exemple : {@code "Franc CFA (UEMOA)"}. * Exemple : {@code "Franc CFA (UEMOA)"}.
*/ */
@NotBlank @NotBlank
@Size(max = 200) @Size(max = 200)
@Column(name = "libelle", nullable = false, length = 200) @Column(name = "libelle", nullable = false, length = 200)
private String libelle; private String libelle;
/** Description longue optionnelle. */ /** Description longue optionnelle. */
@Size(max = 1000) @Size(max = 1000)
@Column(name = "description", length = 1000) @Column(name = "description", length = 1000)
private String description; private String description;
/** /**
* Classe d'icône pour le rendu UI. * Classe d'icône pour le rendu UI.
* *
* <p> * <p>
* Exemple : {@code "pi-check-circle"}. * Exemple : {@code "pi-check-circle"}.
*/ */
@Size(max = 100) @Size(max = 100)
@Column(name = "icone", length = 100) @Column(name = "icone", length = 100)
private String icone; private String icone;
/** /**
* Code couleur hexadécimal pour le rendu UI. * Code couleur hexadécimal pour le rendu UI.
* *
* <p> * <p>
* Exemple : {@code "#22C55E"}. * Exemple : {@code "#22C55E"}.
*/ */
@Size(max = 50) @Size(max = 50)
@Column(name = "couleur", length = 50) @Column(name = "couleur", length = 50)
private String couleur; private String couleur;
/** /**
* Niveau de sévérité pour les badges PrimeFaces. * Niveau de sévérité pour les badges PrimeFaces.
* *
* <p> * <p>
* Valeurs typiques : {@code success}, * Valeurs typiques : {@code success},
* {@code warning}, {@code danger}, {@code info}. * {@code warning}, {@code danger}, {@code info}.
*/ */
@Size(max = 20) @Size(max = 20)
@Column(name = "severity", length = 20) @Column(name = "severity", length = 20)
private String severity; private String severity;
/** /**
* Ordre d'affichage dans les listes déroulantes. * Ordre d'affichage dans les listes déroulantes.
* *
* <p> * <p>
* Les valeurs avec un ordre inférieur * Les valeurs avec un ordre inférieur
* apparaissent en premier. * apparaissent en premier.
*/ */
@Builder.Default @Builder.Default
@Column(name = "ordre_affichage", nullable = false) @Column(name = "ordre_affichage", nullable = false)
private Integer ordreAffichage = 0; private Integer ordreAffichage = 0;
/** /**
* Indique si cette valeur est la valeur par défaut * Indique si cette valeur est la valeur par défaut
* pour son domaine. Une seule valeur par défaut * pour son domaine. Une seule valeur par défaut
* est autorisée par domaine et organisation. * est autorisée par domaine et organisation.
*/ */
@Builder.Default @Builder.Default
@Column(name = "est_defaut", nullable = false) @Column(name = "est_defaut", nullable = false)
private Boolean estDefaut = false; private Boolean estDefaut = false;
/** /**
* Indique si cette valeur est protégée par le * Indique si cette valeur est protégée par le
* système. Les valeurs système ne peuvent être * système. Les valeurs système ne peuvent être
* ni supprimées ni désactivées par un * ni supprimées ni désactivées par un
* administrateur. * administrateur.
*/ */
@Builder.Default @Builder.Default
@Column(name = "est_systeme", nullable = false) @Column(name = "est_systeme", nullable = false)
private Boolean estSysteme = false; private Boolean estSysteme = false;
/** /**
* Catégorie fonctionnelle (ex: ASSOCIATIF, FINANCIER_SOLIDAIRE, RELIGIEUX…). * Catégorie fonctionnelle (ex: ASSOCIATIF, FINANCIER_SOLIDAIRE, RELIGIEUX…).
* Utilisée pour les types d'organisation (domaine TYPE_ORGANISATION). * Utilisée pour les types d'organisation (domaine TYPE_ORGANISATION).
*/ */
@Size(max = 50) @Size(max = 50)
@Column(name = "categorie", length = 50) @Column(name = "categorie", length = 50)
private String categorie; private String categorie;
/** /**
* Liste CSV des modules activés pour ce type d'organisation. * Liste CSV des modules activés pour ce type d'organisation.
* Exemple : "MEMBRES,COTISATIONS,TONTINE,FINANCE" * Exemple : "MEMBRES,COTISATIONS,TONTINE,FINANCE"
* Utilisée pour initialiser {@code Organisation.modulesActifs} à la création. * Utilisée pour initialiser {@code Organisation.modulesActifs} à la création.
*/ */
@Column(name = "modules_requis", columnDefinition = "TEXT") @Column(name = "modules_requis", columnDefinition = "TEXT")
private String modulesRequis; private String modulesRequis;
/** /**
* Organisation propriétaire de cette valeur. * Organisation propriétaire de cette valeur.
* *
* <p> * <p>
* Lorsque {@code null}, la valeur est globale * Lorsque {@code null}, la valeur est globale
* à la plateforme et visible par toutes les * à la plateforme et visible par toutes les
* organisations. * organisations.
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id") @JoinColumn(name = "organisation_id")
private Organisation organisation; private Organisation organisation;
/** /**
* Callback JPA exécuté avant la persistance. * Callback JPA exécuté avant la persistance.
* *
* <p> * <p>
* Normalise le code et le domaine en * Normalise le code et le domaine en
* majuscules pour garantir la cohérence. * majuscules pour garantir la cohérence.
*/ */
@Override @Override
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (domaine != null) { if (domaine != null) {
domaine = domaine.toUpperCase(); domaine = domaine.toUpperCase();
} }
if (code != null) { if (code != null) {
code = code.toUpperCase(); code = code.toUpperCase();
} }
} }
} }

View File

@@ -1,91 +1,91 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.solidarite.StatutValidationEtape; import dev.lions.unionflow.server.api.enums.solidarite.StatutValidationEtape;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.*; import lombok.*;
/** /**
* Historique des validations pour une demande d'aide. * Historique des validations pour une demande d'aide.
* *
* <p>Chaque ligne représente l'état d'une étape du workflow pour une demande. * <p>Chaque ligne représente l'état d'une étape du workflow pour une demande.
* La délégation de véto (valideur absent) est tracée avec motif — conformité BCEAO/OHADA. * La délégation de véto (valideur absent) est tracée avec motif — conformité BCEAO/OHADA.
* *
* <p>Table : {@code validation_etapes_demande} * <p>Table : {@code validation_etapes_demande}
*/ */
@Entity @Entity
@Table( @Table(
name = "validation_etapes_demande", name = "validation_etapes_demande",
indexes = { indexes = {
@Index(name = "idx_ved_demande", columnList = "demande_aide_id"), @Index(name = "idx_ved_demande", columnList = "demande_aide_id"),
@Index(name = "idx_ved_valideur", columnList = "valideur_id"), @Index(name = "idx_ved_valideur", columnList = "valideur_id"),
@Index(name = "idx_ved_statut", columnList = "statut") @Index(name = "idx_ved_statut", columnList = "statut")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ValidationEtapeDemande extends BaseEntity { public class ValidationEtapeDemande extends BaseEntity {
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "demande_aide_id", nullable = false) @JoinColumn(name = "demande_aide_id", nullable = false)
private DemandeAide demandeAide; private DemandeAide demandeAide;
@NotNull @NotNull
@Min(1) @Max(3) @Min(1) @Max(3)
@Column(name = "etape_numero", nullable = false) @Column(name = "etape_numero", nullable = false)
private Integer etapeNumero; private Integer etapeNumero;
/** Valideur assigné à cette étape */ /** Valideur assigné à cette étape */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "valideur_id") @JoinColumn(name = "valideur_id")
private Membre valideur; private Membre valideur;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Builder.Default @Builder.Default
@Column(name = "statut", nullable = false, length = 20) @Column(name = "statut", nullable = false, length = 20)
private StatutValidationEtape statut = StatutValidationEtape.EN_ATTENTE; private StatutValidationEtape statut = StatutValidationEtape.EN_ATTENTE;
@Column(name = "date_validation") @Column(name = "date_validation")
private LocalDateTime dateValidation; private LocalDateTime dateValidation;
@Column(name = "commentaire", length = 1000) @Column(name = "commentaire", length = 1000)
private String commentaire; private String commentaire;
/** /**
* Valideur supérieur qui a désactivé le véto de {@code valideur}. * Valideur supérieur qui a désactivé le véto de {@code valideur}.
* Renseigné uniquement en cas de délégation. * Renseigné uniquement en cas de délégation.
*/ */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "delegue_par_id") @JoinColumn(name = "delegue_par_id")
private Membre deleguePar; private Membre deleguePar;
/** /**
* Motif et trace de la délégation — obligatoire si {@code deleguePar} est renseigné. * Motif et trace de la délégation — obligatoire si {@code deleguePar} est renseigné.
* Conservé 10 ans — exigence BCEAO/OHADA/Fiscalité ivoirienne. * Conservé 10 ans — exigence BCEAO/OHADA/Fiscalité ivoirienne.
*/ */
@Column(name = "trace_delegation", columnDefinition = "TEXT") @Column(name = "trace_delegation", columnDefinition = "TEXT")
private String traceDelegation; private String traceDelegation;
// ── Méthodes métier ──────────────────────────────────────────────────────── // ── Méthodes métier ────────────────────────────────────────────────────────
public boolean estEnAttente() { public boolean estEnAttente() {
return StatutValidationEtape.EN_ATTENTE.equals(statut); return StatutValidationEtape.EN_ATTENTE.equals(statut);
} }
public boolean estFinalisee() { public boolean estFinalisee() {
return StatutValidationEtape.APPROUVEE.equals(statut) return StatutValidationEtape.APPROUVEE.equals(statut)
|| StatutValidationEtape.REJETEE.equals(statut) || StatutValidationEtape.REJETEE.equals(statut)
|| StatutValidationEtape.DELEGUEE.equals(statut) || StatutValidationEtape.DELEGUEE.equals(statut)
|| StatutValidationEtape.EXPIREE.equals(statut); || StatutValidationEtape.EXPIREE.equals(statut);
} }
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (statut == null) statut = StatutValidationEtape.EN_ATTENTE; if (statut == null) statut = StatutValidationEtape.EN_ATTENTE;
} }
} }

View File

@@ -1,114 +1,114 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.wave.StatutWebhook; import dev.lions.unionflow.server.api.enums.wave.StatutWebhook;
import dev.lions.unionflow.server.api.enums.wave.TypeEvenementWebhook; import dev.lions.unionflow.server.api.enums.wave.TypeEvenementWebhook;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** /**
* Entité WebhookWave pour le traitement des événements Wave * Entité WebhookWave pour le traitement des événements Wave
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2025-01-29 * @since 2025-01-29
*/ */
@Entity @Entity
@Table(name = "webhooks_wave", indexes = { @Table(name = "webhooks_wave", indexes = {
@Index(name = "idx_webhook_wave_event_id", columnList = "wave_event_id", unique = true), @Index(name = "idx_webhook_wave_event_id", columnList = "wave_event_id", unique = true),
@Index(name = "idx_webhook_wave_statut", columnList = "statut_traitement"), @Index(name = "idx_webhook_wave_statut", columnList = "statut_traitement"),
@Index(name = "idx_webhook_wave_type", columnList = "type_evenement"), @Index(name = "idx_webhook_wave_type", columnList = "type_evenement"),
@Index(name = "idx_webhook_wave_transaction", columnList = "transaction_wave_id"), @Index(name = "idx_webhook_wave_transaction", columnList = "transaction_wave_id"),
@Index(name = "idx_webhook_wave_paiement", columnList = "paiement_id") @Index(name = "idx_webhook_wave_paiement", columnList = "paiement_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class WebhookWave extends BaseEntity { public class WebhookWave extends BaseEntity {
/** Identifiant unique de l'événement Wave */ /** Identifiant unique de l'événement Wave */
@NotBlank @NotBlank
@Column(name = "wave_event_id", unique = true, nullable = false, length = 100) @Column(name = "wave_event_id", unique = true, nullable = false, length = 100)
private String waveEventId; private String waveEventId;
/** Type d'événement */ /** Type d'événement */
@Column(name = "type_evenement", length = 50) @Column(name = "type_evenement", length = 50)
private String typeEvenement; private String typeEvenement;
/** Statut de traitement */ /** Statut de traitement */
@Builder.Default @Builder.Default
@Column(name = "statut_traitement", nullable = false, length = 30) @Column(name = "statut_traitement", nullable = false, length = 30)
private String statutTraitement = StatutWebhook.EN_ATTENTE.name(); private String statutTraitement = StatutWebhook.EN_ATTENTE.name();
/** Payload JSON reçu */ /** Payload JSON reçu */
@Column(name = "payload", columnDefinition = "TEXT") @Column(name = "payload", columnDefinition = "TEXT")
private String payload; private String payload;
/** Signature de validation */ /** Signature de validation */
@Column(name = "signature", length = 500) @Column(name = "signature", length = 500)
private String signature; private String signature;
/** Date de réception */ /** Date de réception */
@Column(name = "date_reception") @Column(name = "date_reception")
private LocalDateTime dateReception; private LocalDateTime dateReception;
/** Date de traitement */ /** Date de traitement */
@Column(name = "date_traitement") @Column(name = "date_traitement")
private LocalDateTime dateTraitement; private LocalDateTime dateTraitement;
/** Nombre de tentatives de traitement */ /** Nombre de tentatives de traitement */
@Builder.Default @Builder.Default
@Column(name = "nombre_tentatives", nullable = false) @Column(name = "nombre_tentatives", nullable = false)
private Integer nombreTentatives = 0; private Integer nombreTentatives = 0;
/** Message d'erreur (si échec) */ /** Message d'erreur (si échec) */
@Column(name = "message_erreur", length = 1000) @Column(name = "message_erreur", length = 1000)
private String messageErreur; private String messageErreur;
/** Commentaires */ /** Commentaires */
@Column(name = "commentaire", length = 500) @Column(name = "commentaire", length = 500)
private String commentaire; private String commentaire;
// Relations // Relations
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "transaction_wave_id") @JoinColumn(name = "transaction_wave_id")
private TransactionWave transactionWave; private TransactionWave transactionWave;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "paiement_id") @JoinColumn(name = "paiement_id")
private Versement versement; private Versement versement;
/** Méthode métier pour vérifier si le webhook est traité */ /** Méthode métier pour vérifier si le webhook est traité */
public boolean isTraite() { public boolean isTraite() {
return StatutWebhook.TRAITE.name().equals(statutTraitement); return StatutWebhook.TRAITE.name().equals(statutTraitement);
} }
/** Méthode métier pour vérifier si le webhook peut être retenté */ /** Méthode métier pour vérifier si le webhook peut être retenté */
public boolean peutEtreRetente() { public boolean peutEtreRetente() {
return (StatutWebhook.ECHOUE.name().equals(statutTraitement) return (StatutWebhook.ECHOUE.name().equals(statutTraitement)
|| StatutWebhook.EN_ATTENTE.name().equals(statutTraitement)) || StatutWebhook.EN_ATTENTE.name().equals(statutTraitement))
&& (nombreTentatives == null || nombreTentatives < 5); && (nombreTentatives == null || nombreTentatives < 5);
} }
/** Callback JPA avant la persistance */ /** Callback JPA avant la persistance */
@PrePersist @PrePersist
protected void onCreate() { protected void onCreate() {
super.onCreate(); super.onCreate();
if (statutTraitement == null) { if (statutTraitement == null) {
statutTraitement = StatutWebhook.EN_ATTENTE.name(); statutTraitement = StatutWebhook.EN_ATTENTE.name();
} }
if (dateReception == null) { if (dateReception == null) {
dateReception = LocalDateTime.now(); dateReception = LocalDateTime.now();
} }
if (nombreTentatives == null) { if (nombreTentatives == null) {
nombreTentatives = 0; nombreTentatives = 0;
} }
} }
} }

View File

@@ -1,66 +1,66 @@
package dev.lions.unionflow.server.entity; package dev.lions.unionflow.server.entity;
import dev.lions.unionflow.server.api.enums.solidarite.TypeWorkflow; import dev.lions.unionflow.server.api.enums.solidarite.TypeWorkflow;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.*; import jakarta.validation.constraints.*;
import lombok.*; import lombok.*;
/** /**
* Configuration du workflow de validation pour une organisation. * Configuration du workflow de validation pour une organisation.
* *
* <p>Maximum 3 étapes ordonnées. Chaque étape requiert un rôle spécifique. * <p>Maximum 3 étapes ordonnées. Chaque étape requiert un rôle spécifique.
* Exemple Mutuelle Y : Secrétaire (étape 1) → Trésorier (étape 2) → Président (étape 3). * Exemple Mutuelle Y : Secrétaire (étape 1) → Trésorier (étape 2) → Président (étape 3).
* *
* <p>Table : {@code workflow_validation_config} * <p>Table : {@code workflow_validation_config}
*/ */
@Entity @Entity
@Table( @Table(
name = "workflow_validation_config", name = "workflow_validation_config",
indexes = { indexes = {
@Index(name = "idx_wf_organisation", columnList = "organisation_id"), @Index(name = "idx_wf_organisation", columnList = "organisation_id"),
@Index(name = "idx_wf_type", columnList = "type_workflow") @Index(name = "idx_wf_type", columnList = "type_workflow")
}, },
uniqueConstraints = { uniqueConstraints = {
@UniqueConstraint( @UniqueConstraint(
name = "uk_wf_org_type_etape", name = "uk_wf_org_type_etape",
columnNames = {"organisation_id", "type_workflow", "etape_numero"}) columnNames = {"organisation_id", "type_workflow", "etape_numero"})
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class WorkflowValidationConfig extends BaseEntity { public class WorkflowValidationConfig extends BaseEntity {
@NotNull @NotNull
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@NotNull @NotNull
@Builder.Default @Builder.Default
@Column(name = "type_workflow", nullable = false, length = 30) @Column(name = "type_workflow", nullable = false, length = 30)
private TypeWorkflow typeWorkflow = TypeWorkflow.DEMANDE_AIDE; private TypeWorkflow typeWorkflow = TypeWorkflow.DEMANDE_AIDE;
/** Numéro d'ordre de l'étape (1, 2 ou 3) */ /** Numéro d'ordre de l'étape (1, 2 ou 3) */
@NotNull @NotNull
@Min(1) @Max(3) @Min(1) @Max(3)
@Column(name = "etape_numero", nullable = false) @Column(name = "etape_numero", nullable = false)
private Integer etapeNumero; private Integer etapeNumero;
/** Rôle nécessaire pour valider cette étape */ /** Rôle nécessaire pour valider cette étape */
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "role_requis_id") @JoinColumn(name = "role_requis_id")
private Role roleRequis; private Role roleRequis;
@NotBlank @NotBlank
@Column(name = "libelle_etape", nullable = false, length = 200) @Column(name = "libelle_etape", nullable = false, length = 200)
private String libelleEtape; private String libelleEtape;
/** Délai maximum en heures avant expiration automatique (SLA) */ /** Délai maximum en heures avant expiration automatique (SLA) */
@Builder.Default @Builder.Default
@Min(1) @Min(1)
@Column(name = "delai_max_heures", nullable = false) @Column(name = "delai_max_heures", nullable = false)
private Integer delaiMaxHeures = 72; private Integer delaiMaxHeures = 72;
} }

View File

@@ -1,50 +1,50 @@
package dev.lions.unionflow.server.entity.agricole; package dev.lions.unionflow.server.entity.agricole;
import dev.lions.unionflow.server.api.enums.agricole.StatutCampagneAgricole; import dev.lions.unionflow.server.api.enums.agricole.StatutCampagneAgricole;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
@Entity @Entity
@Table(name = "campagnes_agricoles", indexes = { @Table(name = "campagnes_agricoles", indexes = {
@Index(name = "idx_agricole_organisation", columnList = "organisation_id") @Index(name = "idx_agricole_organisation", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class CampagneAgricole extends BaseEntity { public class CampagneAgricole extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotBlank @NotBlank
@Column(name = "designation", nullable = false, length = 200) @Column(name = "designation", nullable = false, length = 200)
private String designation; private String designation;
@Column(name = "type_culture", length = 100) @Column(name = "type_culture", length = 100)
private String typeCulturePrincipale; private String typeCulturePrincipale;
@Column(name = "surface_estimee_ha", precision = 19, scale = 4) @Column(name = "surface_estimee_ha", precision = 19, scale = 4)
private BigDecimal surfaceTotaleEstimeeHectares; private BigDecimal surfaceTotaleEstimeeHectares;
@Column(name = "volume_prev_tonnes", precision = 19, scale = 4) @Column(name = "volume_prev_tonnes", precision = 19, scale = 4)
private BigDecimal volumePrevisionnelTonnes; private BigDecimal volumePrevisionnelTonnes;
@Column(name = "volume_reel_tonnes", precision = 19, scale = 4) @Column(name = "volume_reel_tonnes", precision = 19, scale = 4)
private BigDecimal volumeReelTonnes; private BigDecimal volumeReelTonnes;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutCampagneAgricole statut = StatutCampagneAgricole.PREPARATION; private StatutCampagneAgricole statut = StatutCampagneAgricole.PREPARATION;
} }

View File

@@ -1,71 +1,71 @@
package dev.lions.unionflow.server.entity.collectefonds; package dev.lions.unionflow.server.entity.collectefonds;
import dev.lions.unionflow.server.api.enums.collectefonds.StatutCampagneCollecte; import dev.lions.unionflow.server.api.enums.collectefonds.StatutCampagneCollecte;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "campagnes_collecte", indexes = { @Table(name = "campagnes_collecte", indexes = {
@Index(name = "idx_collecte_organisation", columnList = "organisation_id") @Index(name = "idx_collecte_organisation", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class CampagneCollecte extends BaseEntity { public class CampagneCollecte extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotBlank @NotBlank
@Column(name = "titre", nullable = false, length = 200) @Column(name = "titre", nullable = false, length = 200)
private String titre; private String titre;
@Column(name = "courte_description", length = 500) @Column(name = "courte_description", length = 500)
private String courteDescription; private String courteDescription;
@Column(name = "html_description_complete", columnDefinition = "TEXT") @Column(name = "html_description_complete", columnDefinition = "TEXT")
private String htmlDescriptionComplete; private String htmlDescriptionComplete;
@Column(name = "image_banniere_url", length = 500) @Column(name = "image_banniere_url", length = 500)
private String imageBanniereUrl; private String imageBanniereUrl;
@Column(name = "objectif_financier", precision = 19, scale = 4) @Column(name = "objectif_financier", precision = 19, scale = 4)
private BigDecimal objectifFinancier; private BigDecimal objectifFinancier;
@Column(name = "montant_collecte_actuel", precision = 19, scale = 4) @Column(name = "montant_collecte_actuel", precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal montantCollecteActuel = BigDecimal.ZERO; private BigDecimal montantCollecteActuel = BigDecimal.ZERO;
@Column(name = "nombre_donateurs") @Column(name = "nombre_donateurs")
@Builder.Default @Builder.Default
private Integer nombreDonateurs = 0; private Integer nombreDonateurs = 0;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutCampagneCollecte statut = StatutCampagneCollecte.BROUILLON; private StatutCampagneCollecte statut = StatutCampagneCollecte.BROUILLON;
@NotNull @NotNull
@Column(name = "date_ouverture", nullable = false) @Column(name = "date_ouverture", nullable = false)
@Builder.Default @Builder.Default
private LocalDateTime dateOuverture = LocalDateTime.now(); private LocalDateTime dateOuverture = LocalDateTime.now();
@Column(name = "date_cloture_prevue") @Column(name = "date_cloture_prevue")
private LocalDateTime dateCloturePrevue; private LocalDateTime dateCloturePrevue;
@Column(name = "est_publique", nullable = false) @Column(name = "est_publique", nullable = false)
@Builder.Default @Builder.Default
private Boolean estPublique = true; private Boolean estPublique = true;
} }

View File

@@ -1,59 +1,59 @@
package dev.lions.unionflow.server.entity.collectefonds; package dev.lions.unionflow.server.entity.collectefonds;
import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave; import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Membre;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "contributions_collecte", indexes = { @Table(name = "contributions_collecte", indexes = {
@Index(name = "idx_contribution_campagne", columnList = "campagne_id"), @Index(name = "idx_contribution_campagne", columnList = "campagne_id"),
@Index(name = "idx_contribution_membre", columnList = "membre_donateur_id") @Index(name = "idx_contribution_membre", columnList = "membre_donateur_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ContributionCollecte extends BaseEntity { public class ContributionCollecte extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "campagne_id", nullable = false) @JoinColumn(name = "campagne_id", nullable = false)
private CampagneCollecte campagne; private CampagneCollecte campagne;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_donateur_id") @JoinColumn(name = "membre_donateur_id")
private Membre membreDonateur; private Membre membreDonateur;
@Column(name = "alias_donateur", length = 150) @Column(name = "alias_donateur", length = 150)
private String aliasDonateur; private String aliasDonateur;
@Column(name = "est_anonyme", nullable = false) @Column(name = "est_anonyme", nullable = false)
@Builder.Default @Builder.Default
private Boolean estAnonyme = false; private Boolean estAnonyme = false;
@NotNull @NotNull
@Column(name = "montant_soutien", nullable = false, precision = 19, scale = 4) @Column(name = "montant_soutien", nullable = false, precision = 19, scale = 4)
private BigDecimal montantSoutien; private BigDecimal montantSoutien;
@Column(name = "message_soutien", length = 500) @Column(name = "message_soutien", length = 500)
private String messageSoutien; private String messageSoutien;
@NotNull @NotNull
@Column(name = "date_contribution", nullable = false) @Column(name = "date_contribution", nullable = false)
@Builder.Default @Builder.Default
private LocalDateTime dateContribution = LocalDateTime.now(); private LocalDateTime dateContribution = LocalDateTime.now();
@Column(name = "transaction_paiement_id", length = 100) @Column(name = "transaction_paiement_id", length = 100)
private String transactionPaiementId; private String transactionPaiementId;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut_paiement", length = 50) @Column(name = "statut_paiement", length = 50)
private StatutTransactionWave statutPaiement; private StatutTransactionWave statutPaiement;
} }

View File

@@ -1,51 +1,51 @@
package dev.lions.unionflow.server.entity.culte; package dev.lions.unionflow.server.entity.culte;
import dev.lions.unionflow.server.api.enums.culte.TypeDonReligieux; import dev.lions.unionflow.server.api.enums.culte.TypeDonReligieux;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "dons_religieux", indexes = { @Table(name = "dons_religieux", indexes = {
@Index(name = "idx_don_c_organisation", columnList = "institution_id"), @Index(name = "idx_don_c_organisation", columnList = "institution_id"),
@Index(name = "idx_don_c_fidele", columnList = "fidele_id") @Index(name = "idx_don_c_fidele", columnList = "fidele_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class DonReligieux extends BaseEntity { public class DonReligieux extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "institution_id", nullable = false) @JoinColumn(name = "institution_id", nullable = false)
private Organisation institution; private Organisation institution;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "fidele_id") @JoinColumn(name = "fidele_id")
private Membre fidele; private Membre fidele;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_don", nullable = false, length = 50) @Column(name = "type_don", nullable = false, length = 50)
private TypeDonReligieux typeDon; private TypeDonReligieux typeDon;
@NotNull @NotNull
@Column(name = "montant", nullable = false, precision = 19, scale = 4) @Column(name = "montant", nullable = false, precision = 19, scale = 4)
private BigDecimal montant; private BigDecimal montant;
@NotNull @NotNull
@Column(name = "date_encaissement", nullable = false) @Column(name = "date_encaissement", nullable = false)
@Builder.Default @Builder.Default
private LocalDateTime dateEncaissement = LocalDateTime.now(); private LocalDateTime dateEncaissement = LocalDateTime.now();
@Column(name = "periode_nature", length = 150) @Column(name = "periode_nature", length = 150)
private String periodeOuNatureAssociee; private String periodeOuNatureAssociee;
} }

View File

@@ -1,43 +1,43 @@
package dev.lions.unionflow.server.entity.gouvernance; package dev.lions.unionflow.server.entity.gouvernance;
import dev.lions.unionflow.server.api.enums.gouvernance.NiveauEchelon; import dev.lions.unionflow.server.api.enums.gouvernance.NiveauEchelon;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
@Entity @Entity
@Table(name = "echelons_organigramme", indexes = { @Table(name = "echelons_organigramme", indexes = {
@Index(name = "idx_echelon_org", columnList = "organisation_id"), @Index(name = "idx_echelon_org", columnList = "organisation_id"),
@Index(name = "idx_echelon_parent", columnList = "echelon_parent_id") @Index(name = "idx_echelon_parent", columnList = "echelon_parent_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class EchelonOrganigramme extends BaseEntity { public class EchelonOrganigramme extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "echelon_parent_id") @JoinColumn(name = "echelon_parent_id")
private Organisation echelonParent; private Organisation echelonParent;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "niveau_echelon", nullable = false, length = 50) @Column(name = "niveau_echelon", nullable = false, length = 50)
private NiveauEchelon niveau; private NiveauEchelon niveau;
@NotBlank @NotBlank
@Column(name = "designation", nullable = false, length = 200) @Column(name = "designation", nullable = false, length = 200)
private String designation; private String designation;
@Column(name = "zone_delegation", length = 200) @Column(name = "zone_delegation", length = 200)
private String zoneGeographiqueOuDelegation; private String zoneGeographiqueOuDelegation;
} }

View File

@@ -1,106 +1,106 @@
package dev.lions.unionflow.server.entity.listener; package dev.lions.unionflow.server.entity.listener;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.service.KeycloakService; import dev.lions.unionflow.server.service.KeycloakService;
import io.quarkus.arc.Arc; import io.quarkus.arc.Arc;
import jakarta.persistence.PrePersist; import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate; import jakarta.persistence.PreUpdate;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
/** /**
* Listener JPA pour l'alimentation automatique * Listener JPA pour l'alimentation automatique
* des champs d'audit. * des champs d'audit.
* *
* <p> * <p>
* Renseigne automatiquement {@code creePar} lors * Renseigne automatiquement {@code creePar} lors
* de la création et {@code modifiePar} lors de la * de la création et {@code modifiePar} lors de la
* mise à jour, en récupérant l'email de * mise à jour, en récupérant l'email de
* l'utilisateur authentifié via * l'utilisateur authentifié via
* {@link KeycloakService}. * {@link KeycloakService}.
* *
* <p> * <p>
* Ce listener est référencé via * Ce listener est référencé via
* {@code @EntityListeners} sur {@link BaseEntity}, * {@code @EntityListeners} sur {@link BaseEntity},
* garantissant que <strong>toutes</strong> les * garantissant que <strong>toutes</strong> les
* entités héritent automatiquement de ce * entités héritent automatiquement de ce
* comportement (WOU strict). * comportement (WOU strict).
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 3.0 * @version 3.0
* @since 2026-02-21 * @since 2026-02-21
*/ */
public class AuditEntityListener { public class AuditEntityListener {
/** /**
* Utilisateur par défaut pour les opérations * Utilisateur par défaut pour les opérations
* système sans contexte de sécurité. * système sans contexte de sécurité.
*/ */
private static final String UTILISATEUR_SYSTEME = "system"; private static final String UTILISATEUR_SYSTEME = "system";
private static final Logger LOG = Logger.getLogger(AuditEntityListener.class); private static final Logger LOG = Logger.getLogger(AuditEntityListener.class);
/** /**
* Callback exécuté avant la persistance. * Callback exécuté avant la persistance.
* *
* <p> * <p>
* Renseigne {@code creePar} avec l'email * Renseigne {@code creePar} avec l'email
* de l'utilisateur authentifié, ou * de l'utilisateur authentifié, ou
* {@code "system"} si aucun contexte de * {@code "system"} si aucun contexte de
* sécurité n'est disponible. * sécurité n'est disponible.
* *
* @param entity l'entité en cours de création * @param entity l'entité en cours de création
*/ */
@PrePersist @PrePersist
public void avantCreation(BaseEntity entity) { public void avantCreation(BaseEntity entity) {
if (entity.getCreePar() == null if (entity.getCreePar() == null
|| entity.getCreePar().isBlank()) { || entity.getCreePar().isBlank()) {
entity.setCreePar( entity.setCreePar(
obtenirUtilisateurCourant()); obtenirUtilisateurCourant());
} }
} }
/** /**
* Callback exécuté avant la mise à jour. * Callback exécuté avant la mise à jour.
* *
* <p> * <p>
* Renseigne {@code modifiePar} avec l'email * Renseigne {@code modifiePar} avec l'email
* de l'utilisateur authentifié. * de l'utilisateur authentifié.
* *
* @param entity l'entité en cours de modification * @param entity l'entité en cours de modification
*/ */
@PreUpdate @PreUpdate
public void avantModification(BaseEntity entity) { public void avantModification(BaseEntity entity) {
entity.setModifiePar( entity.setModifiePar(
obtenirUtilisateurCourant()); obtenirUtilisateurCourant());
} }
/** /**
* Obtient l'email de l'utilisateur courant. * Obtient l'email de l'utilisateur courant.
* *
* <p> * <p>
* Utilise {@link Arc#container()} pour * Utilise {@link Arc#container()} pour
* résoudre le {@link KeycloakService} depuis * résoudre le {@link KeycloakService} depuis
* le conteneur CDI de Quarkus. * le conteneur CDI de Quarkus.
* *
* @return l'email ou {@code "system"} en fallback * @return l'email ou {@code "system"} en fallback
*/ */
private String obtenirUtilisateurCourant() { private String obtenirUtilisateurCourant() {
try { try {
KeycloakService keycloakService = Arc.container() KeycloakService keycloakService = Arc.container()
.instance(KeycloakService.class) .instance(KeycloakService.class)
.get(); .get();
if (keycloakService != null if (keycloakService != null
&& keycloakService.isAuthenticated()) { && keycloakService.isAuthenticated()) {
String email = keycloakService.getCurrentUserEmail(); String email = keycloakService.getCurrentUserEmail();
if (email != null && !email.isBlank()) { if (email != null && !email.isBlank()) {
return email; return email;
} }
} }
} catch (Exception e) { } catch (Exception e) {
LOG.debugf( LOG.debugf(
"Contexte de sécurité indisponible: %s", "Contexte de sécurité indisponible: %s",
e.getMessage()); e.getMessage());
} }
return UTILISATEUR_SYSTEME; return UTILISATEUR_SYSTEME;
} }
} }

View File

@@ -0,0 +1,68 @@
package dev.lions.unionflow.server.entity.mutuelle;
import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "parametres_financiers_mutuelle", indexes = {
@Index(name = "idx_pfm_org", columnList = "organisation_id", unique = true)
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ParametresFinanciersMutuelle extends BaseEntity {
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false, unique = true)
private Organisation organisation;
/** Valeur nominale par défaut d'une part sociale */
@NotNull
@Column(name = "valeur_nominale_par_defaut", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal valeurNominaleParDefaut = new BigDecimal("5000");
/** Taux d'intérêt annuel sur l'épargne, ex: 0.03 = 3% */
@NotNull
@Column(name = "taux_interet_annuel_epargne", nullable = false, precision = 6, scale = 4)
@Builder.Default
private BigDecimal tauxInteretAnnuelEpargne = new BigDecimal("0.03");
/** Taux de dividende annuel sur les parts sociales, ex: 0.05 = 5% */
@NotNull
@Column(name = "taux_dividende_parts_annuel", nullable = false, precision = 6, scale = 4)
@Builder.Default
private BigDecimal tauxDividendePartsAnnuel = new BigDecimal("0.05");
/** MENSUEL | TRIMESTRIEL | ANNUEL */
@NotNull
@Column(name = "periodicite_calcul", nullable = false, length = 20)
@Builder.Default
private String periodiciteCalcul = "MENSUEL";
/** Solde minimum en dessous duquel les intérêts ne s'appliquent pas */
@Column(name = "seuil_min_epargne_interets", precision = 19, scale = 4)
@Builder.Default
private BigDecimal seuilMinEpargneInterets = BigDecimal.ZERO;
/** Date du prochain calcul planifié */
@Column(name = "prochaine_calcul_interets")
private LocalDate prochaineCalculInterets;
/** Date du dernier calcul effectué */
@Column(name = "dernier_calcul_interets")
private LocalDate dernierCalculInterets;
/** Nombre de comptes traités lors du dernier calcul */
@Column(name = "dernier_nb_comptes_traites")
@Builder.Default
private Integer dernierNbComptesTraites = 0;
}

View File

@@ -1,97 +1,97 @@
package dev.lions.unionflow.server.entity.mutuelle.credit; package dev.lions.unionflow.server.entity.mutuelle.credit;
import dev.lions.unionflow.server.api.enums.mutuelle.credit.StatutDemandeCredit; import dev.lions.unionflow.server.api.enums.mutuelle.credit.StatutDemandeCredit;
import dev.lions.unionflow.server.api.enums.mutuelle.credit.TypeCredit; import dev.lions.unionflow.server.api.enums.mutuelle.credit.TypeCredit;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne; import dev.lions.unionflow.server.entity.mutuelle.epargne.CompteEpargne;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@Entity @Entity
@Table(name = "demandes_credit", indexes = { @Table(name = "demandes_credit", indexes = {
@Index(name = "idx_credit_membre", columnList = "membre_id"), @Index(name = "idx_credit_membre", columnList = "membre_id"),
@Index(name = "idx_credit_numero", columnList = "numero_dossier", unique = true) @Index(name = "idx_credit_numero", columnList = "numero_dossier", unique = true)
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class DemandeCredit extends BaseEntity { public class DemandeCredit extends BaseEntity {
@Column(name = "numero_dossier", unique = true, nullable = false, length = 50) @Column(name = "numero_dossier", unique = true, nullable = false, length = 50)
private String numeroDossier; private String numeroDossier;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false) @JoinColumn(name = "membre_id", nullable = false)
private Membre membre; private Membre membre;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_credit", nullable = false, length = 50) @Column(name = "type_credit", nullable = false, length = 50)
private TypeCredit typeCredit; private TypeCredit typeCredit;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "compte_lie_id") @JoinColumn(name = "compte_lie_id")
private CompteEpargne compteLie; private CompteEpargne compteLie;
@NotNull @NotNull
@Column(name = "montant_demande", nullable = false, precision = 19, scale = 4) @Column(name = "montant_demande", nullable = false, precision = 19, scale = 4)
private BigDecimal montantDemande; private BigDecimal montantDemande;
@NotNull @NotNull
@Column(name = "duree_mois_demande", nullable = false) @Column(name = "duree_mois_demande", nullable = false)
private Integer dureeMoisDemande; private Integer dureeMoisDemande;
@Column(name = "justification_detaillee", columnDefinition = "TEXT") @Column(name = "justification_detaillee", columnDefinition = "TEXT")
private String justificationDetaillee; private String justificationDetaillee;
@Column(name = "montant_approuve", precision = 19, scale = 4) @Column(name = "montant_approuve", precision = 19, scale = 4)
private BigDecimal montantApprouve; private BigDecimal montantApprouve;
@Column(name = "duree_mois_approuvee") @Column(name = "duree_mois_approuvee")
private Integer dureeMoisApprouvee; private Integer dureeMoisApprouvee;
@Column(name = "taux_interet_annuel", precision = 5, scale = 2) @Column(name = "taux_interet_annuel", precision = 5, scale = 2)
private BigDecimal tauxInteretAnnuel; private BigDecimal tauxInteretAnnuel;
@Column(name = "cout_total_credit", precision = 19, scale = 4) @Column(name = "cout_total_credit", precision = 19, scale = 4)
private BigDecimal coutTotalCredit; private BigDecimal coutTotalCredit;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutDemandeCredit statut = StatutDemandeCredit.SOUMISE; private StatutDemandeCredit statut = StatutDemandeCredit.SOUMISE;
@Column(name = "notes_comite", columnDefinition = "TEXT") @Column(name = "notes_comite", columnDefinition = "TEXT")
private String notesComite; private String notesComite;
@NotNull @NotNull
@Column(name = "date_soumission", nullable = false) @Column(name = "date_soumission", nullable = false)
@Builder.Default @Builder.Default
private LocalDate dateSoumission = LocalDate.now(); private LocalDate dateSoumission = LocalDate.now();
@Column(name = "date_validation") @Column(name = "date_validation")
private LocalDate dateValidation; private LocalDate dateValidation;
@Column(name = "date_premier_echeance") @Column(name = "date_premier_echeance")
private LocalDate datePremierEcheance; private LocalDate datePremierEcheance;
@OneToMany(mappedBy = "demandeCredit", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "demandeCredit", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default @Builder.Default
private List<GarantieDemande> garanties = new ArrayList<>(); private List<GarantieDemande> garanties = new ArrayList<>();
@OneToMany(mappedBy = "demandeCredit", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "demandeCredit", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("ordre ASC") @OrderBy("ordre ASC")
@Builder.Default @Builder.Default
private List<EcheanceCredit> echeancier = new ArrayList<>(); private List<EcheanceCredit> echeancier = new ArrayList<>();
} }

View File

@@ -1,69 +1,69 @@
package dev.lions.unionflow.server.entity.mutuelle.credit; package dev.lions.unionflow.server.entity.mutuelle.credit;
import dev.lions.unionflow.server.api.enums.mutuelle.credit.StatutEcheanceCredit; import dev.lions.unionflow.server.api.enums.mutuelle.credit.StatutEcheanceCredit;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
@Entity @Entity
@Table(name = "echeances_credit", indexes = { @Table(name = "echeances_credit", indexes = {
@Index(name = "idx_echeance_demande", columnList = "demande_credit_id") @Index(name = "idx_echeance_demande", columnList = "demande_credit_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@ToString(exclude = "demandeCredit") @ToString(exclude = "demandeCredit")
public class EcheanceCredit extends BaseEntity { public class EcheanceCredit extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "demande_credit_id", nullable = false) @JoinColumn(name = "demande_credit_id", nullable = false)
private DemandeCredit demandeCredit; private DemandeCredit demandeCredit;
@NotNull @NotNull
@Column(name = "ordre", nullable = false) @Column(name = "ordre", nullable = false)
private Integer ordre; private Integer ordre;
@NotNull @NotNull
@Column(name = "date_echeance_prevue", nullable = false) @Column(name = "date_echeance_prevue", nullable = false)
private LocalDate dateEcheancePrevue; private LocalDate dateEcheancePrevue;
@Column(name = "date_paiement_effectif") @Column(name = "date_paiement_effectif")
private LocalDate datePaiementEffectif; private LocalDate datePaiementEffectif;
@NotNull @NotNull
@Column(name = "capital_amorti", nullable = false, precision = 19, scale = 4) @Column(name = "capital_amorti", nullable = false, precision = 19, scale = 4)
private BigDecimal capitalAmorti; private BigDecimal capitalAmorti;
@NotNull @NotNull
@Column(name = "interets_periode", nullable = false, precision = 19, scale = 4) @Column(name = "interets_periode", nullable = false, precision = 19, scale = 4)
private BigDecimal interetsDeLaPeriode; private BigDecimal interetsDeLaPeriode;
@NotNull @NotNull
@Column(name = "montant_total_exigible", nullable = false, precision = 19, scale = 4) @Column(name = "montant_total_exigible", nullable = false, precision = 19, scale = 4)
private BigDecimal montantTotalExigible; private BigDecimal montantTotalExigible;
@NotNull @NotNull
@Column(name = "capital_restant_du", nullable = false, precision = 19, scale = 4) @Column(name = "capital_restant_du", nullable = false, precision = 19, scale = 4)
private BigDecimal capitalRestantDu; private BigDecimal capitalRestantDu;
@Column(name = "penalites_retard", precision = 19, scale = 4) @Column(name = "penalites_retard", precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal penalitesRetard = BigDecimal.ZERO; private BigDecimal penalitesRetard = BigDecimal.ZERO;
@Column(name = "montant_regle", precision = 19, scale = 4) @Column(name = "montant_regle", precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal montantRegle = BigDecimal.ZERO; private BigDecimal montantRegle = BigDecimal.ZERO;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutEcheanceCredit statut = StatutEcheanceCredit.A_VENIR; private StatutEcheanceCredit statut = StatutEcheanceCredit.A_VENIR;
} }

View File

@@ -1,39 +1,39 @@
package dev.lions.unionflow.server.entity.mutuelle.credit; package dev.lions.unionflow.server.entity.mutuelle.credit;
import dev.lions.unionflow.server.api.enums.mutuelle.credit.TypeGarantie; import dev.lions.unionflow.server.api.enums.mutuelle.credit.TypeGarantie;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
@Entity @Entity
@Table(name = "garanties_demande") @Table(name = "garanties_demande")
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@ToString(exclude = "demandeCredit") @ToString(exclude = "demandeCredit")
public class GarantieDemande extends BaseEntity { public class GarantieDemande extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "demande_credit_id", nullable = false) @JoinColumn(name = "demande_credit_id", nullable = false)
private DemandeCredit demandeCredit; private DemandeCredit demandeCredit;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_garantie", nullable = false, length = 50) @Column(name = "type_garantie", nullable = false, length = 50)
private TypeGarantie typeGarantie; private TypeGarantie typeGarantie;
@Column(name = "valeur_estimee", precision = 19, scale = 4) @Column(name = "valeur_estimee", precision = 19, scale = 4)
private BigDecimal valeurEstimee; private BigDecimal valeurEstimee;
@Column(name = "reference_description", length = 500) @Column(name = "reference_description", length = 500)
private String referenceOuDescription; private String referenceOuDescription;
@Column(name = "document_preuve_id", length = 36) @Column(name = "document_preuve_id", length = 36)
private String documentPreuveId; private String documentPreuveId;
} }

View File

@@ -1,73 +1,73 @@
package dev.lions.unionflow.server.entity.mutuelle.epargne; package dev.lions.unionflow.server.entity.mutuelle.epargne;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne; import dev.lions.unionflow.server.api.enums.mutuelle.epargne.StatutCompteEpargne;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeCompteEpargne; import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeCompteEpargne;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
@Entity @Entity
@Table(name = "comptes_epargne", indexes = { @Table(name = "comptes_epargne", indexes = {
@Index(name = "idx_compte_epargne_numero", columnList = "numero_compte", unique = true), @Index(name = "idx_compte_epargne_numero", columnList = "numero_compte", unique = true),
@Index(name = "idx_compte_epargne_membre", columnList = "membre_id"), @Index(name = "idx_compte_epargne_membre", columnList = "membre_id"),
@Index(name = "idx_compte_epargne_orga", columnList = "organisation_id") @Index(name = "idx_compte_epargne_orga", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class CompteEpargne extends BaseEntity { public class CompteEpargne extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false) @JoinColumn(name = "membre_id", nullable = false)
private Membre membre; private Membre membre;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotBlank @NotBlank
@Column(name = "numero_compte", unique = true, nullable = false, length = 50) @Column(name = "numero_compte", unique = true, nullable = false, length = 50)
private String numeroCompte; private String numeroCompte;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_compte", nullable = false, length = 50) @Column(name = "type_compte", nullable = false, length = 50)
private TypeCompteEpargne typeCompte; private TypeCompteEpargne typeCompte;
@NotNull @NotNull
@Column(name = "solde_actuel", nullable = false, precision = 19, scale = 4) @Column(name = "solde_actuel", nullable = false, precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal soldeActuel = BigDecimal.ZERO; private BigDecimal soldeActuel = BigDecimal.ZERO;
@NotNull @NotNull
@Column(name = "solde_bloque", nullable = false, precision = 19, scale = 4) @Column(name = "solde_bloque", nullable = false, precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal soldeBloque = BigDecimal.ZERO; private BigDecimal soldeBloque = BigDecimal.ZERO;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 30) @Column(name = "statut", nullable = false, length = 30)
@Builder.Default @Builder.Default
private StatutCompteEpargne statut = StatutCompteEpargne.ACTIF; private StatutCompteEpargne statut = StatutCompteEpargne.ACTIF;
@NotNull @NotNull
@Column(name = "date_ouverture", nullable = false) @Column(name = "date_ouverture", nullable = false)
@Builder.Default @Builder.Default
private LocalDate dateOuverture = LocalDate.now(); private LocalDate dateOuverture = LocalDate.now();
@Column(name = "date_derniere_transaction") @Column(name = "date_derniere_transaction")
private LocalDate dateDerniereTransaction; private LocalDate dateDerniereTransaction;
@Column(name = "description", length = 500) @Column(name = "description", length = 500)
private String description; private String description;
} }

View File

@@ -1,70 +1,70 @@
package dev.lions.unionflow.server.entity.mutuelle.epargne; package dev.lions.unionflow.server.entity.mutuelle.epargne;
import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne; import dev.lions.unionflow.server.api.enums.mutuelle.epargne.TypeTransactionEpargne;
import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave; import dev.lions.unionflow.server.api.enums.wave.StatutTransactionWave;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "transactions_epargne", indexes = { @Table(name = "transactions_epargne", indexes = {
@Index(name = "idx_tx_epargne_compte", columnList = "compte_id"), @Index(name = "idx_tx_epargne_compte", columnList = "compte_id"),
@Index(name = "idx_tx_epargne_reference", columnList = "reference_externe") @Index(name = "idx_tx_epargne_reference", columnList = "reference_externe")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class TransactionEpargne extends BaseEntity { public class TransactionEpargne extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "compte_id", nullable = false) @JoinColumn(name = "compte_id", nullable = false)
private CompteEpargne compte; private CompteEpargne compte;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_transaction", nullable = false, length = 50) @Column(name = "type_transaction", nullable = false, length = 50)
private TypeTransactionEpargne type; private TypeTransactionEpargne type;
@NotNull @NotNull
@Column(name = "montant", nullable = false, precision = 19, scale = 4) @Column(name = "montant", nullable = false, precision = 19, scale = 4)
private BigDecimal montant; private BigDecimal montant;
@Column(name = "solde_avant", precision = 19, scale = 4) @Column(name = "solde_avant", precision = 19, scale = 4)
private BigDecimal soldeAvant; private BigDecimal soldeAvant;
@Column(name = "solde_apres", precision = 19, scale = 4) @Column(name = "solde_apres", precision = 19, scale = 4)
private BigDecimal soldeApres; private BigDecimal soldeApres;
@Column(name = "motif", length = 500) @Column(name = "motif", length = 500)
private String motif; private String motif;
@NotNull @NotNull
@Column(name = "date_transaction", nullable = false) @Column(name = "date_transaction", nullable = false)
@Builder.Default @Builder.Default
private LocalDateTime dateTransaction = LocalDateTime.now(); private LocalDateTime dateTransaction = LocalDateTime.now();
@Column(name = "operateur_id", length = 36) @Column(name = "operateur_id", length = 36)
private String operateurId; private String operateurId;
@Column(name = "reference_externe", length = 100) @Column(name = "reference_externe", length = 100)
private String referenceExterne; private String referenceExterne;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut_execution", length = 50) @Column(name = "statut_execution", length = 50)
private StatutTransactionWave statutExecution; private StatutTransactionWave statutExecution;
/** Origine des fonds (LCB-FT) — obligatoire au-dessus du seuil configuré */ /** Origine des fonds (LCB-FT) — obligatoire au-dessus du seuil configuré */
@Column(name = "origine_fonds", length = 200) @Column(name = "origine_fonds", length = 200)
private String origineFonds; private String origineFonds;
/** Pièce justificative (document) pour opérations au-dessus du seuil LCB-FT */ /** Pièce justificative (document) pour opérations au-dessus du seuil LCB-FT */
@Column(name = "piece_justificative_id") @Column(name = "piece_justificative_id")
private java.util.UUID pieceJustificativeId; private java.util.UUID pieceJustificativeId;
} }

View File

@@ -0,0 +1,78 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.StatutComptePartsSociales;
import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "comptes_parts_sociales", indexes = {
@Index(name = "idx_cps_numero", columnList = "numero_compte", unique = true),
@Index(name = "idx_cps_membre", columnList = "membre_id"),
@Index(name = "idx_cps_org", columnList = "organisation_id")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class ComptePartsSociales extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false)
private Membre membre;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation;
@NotBlank
@Column(name = "numero_compte", unique = true, nullable = false, length = 50)
private String numeroCompte;
@NotNull
@Min(0)
@Column(name = "nombre_parts", nullable = false)
@Builder.Default
private Integer nombreParts = 0;
@NotNull
@Column(name = "valeur_nominale", nullable = false, precision = 19, scale = 4)
private BigDecimal valeurNominale;
/** nombreParts × valeurNominale — mis à jour à chaque transaction */
@NotNull
@Column(name = "montant_total", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal montantTotal = BigDecimal.ZERO;
@NotNull
@Column(name = "total_dividendes_recus", nullable = false, precision = 19, scale = 4)
@Builder.Default
private BigDecimal totalDividendesRecus = BigDecimal.ZERO;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 30)
@Builder.Default
private StatutComptePartsSociales statut = StatutComptePartsSociales.ACTIF;
@NotNull
@Column(name = "date_ouverture", nullable = false)
@Builder.Default
private LocalDate dateOuverture = LocalDate.now();
@Column(name = "date_derniere_operation")
private LocalDate dateDerniereOperation;
@Column(name = "notes", length = 500)
private String notes;
}

View File

@@ -0,0 +1,61 @@
package dev.lions.unionflow.server.entity.mutuelle.parts;
import dev.lions.unionflow.server.api.enums.mutuelle.parts.TypeTransactionPartsSociales;
import dev.lions.unionflow.server.entity.BaseEntity;
import jakarta.persistence.*;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "transactions_parts_sociales", indexes = {
@Index(name = "idx_tps_compte", columnList = "compte_id"),
@Index(name = "idx_tps_date", columnList = "date_transaction")
})
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EqualsAndHashCode(callSuper = true)
public class TransactionPartsSociales extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "compte_id", nullable = false)
private ComptePartsSociales compte;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "type_transaction", nullable = false, length = 50)
private TypeTransactionPartsSociales typeTransaction;
@NotNull
@Min(1)
@Column(name = "nombre_parts", nullable = false)
private Integer nombreParts;
@NotNull
@Column(name = "montant", nullable = false, precision = 19, scale = 4)
private BigDecimal montant;
@Column(name = "solde_parts_avant", nullable = false)
@Builder.Default
private Integer soldePartsAvant = 0;
@Column(name = "solde_parts_apres", nullable = false)
@Builder.Default
private Integer soldePartsApres = 0;
@Column(name = "motif", length = 500)
private String motif;
@Column(name = "reference_externe", length = 100)
private String referenceExterne;
@NotNull
@Column(name = "date_transaction", nullable = false)
@Builder.Default
private LocalDateTime dateTransaction = LocalDateTime.now();
}

View File

@@ -1,58 +1,58 @@
package dev.lions.unionflow.server.entity.ong; package dev.lions.unionflow.server.entity.ong;
import dev.lions.unionflow.server.api.enums.ong.StatutProjetOng; import dev.lions.unionflow.server.api.enums.ong.StatutProjetOng;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
@Entity @Entity
@Table(name = "projets_ong", indexes = { @Table(name = "projets_ong", indexes = {
@Index(name = "idx_projet_ong_organisation", columnList = "organisation_id") @Index(name = "idx_projet_ong_organisation", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ProjetOng extends BaseEntity { public class ProjetOng extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotBlank @NotBlank
@Column(name = "nom_projet", nullable = false, length = 200) @Column(name = "nom_projet", nullable = false, length = 200)
private String nomProjet; private String nomProjet;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String descriptionMandat; private String descriptionMandat;
@Column(name = "zone_geographique", length = 200) @Column(name = "zone_geographique", length = 200)
private String zoneGeographiqueIntervention; private String zoneGeographiqueIntervention;
@Column(name = "budget_previsionnel", precision = 19, scale = 4) @Column(name = "budget_previsionnel", precision = 19, scale = 4)
private BigDecimal budgetPrevisionnel; private BigDecimal budgetPrevisionnel;
@Column(name = "depenses_reelles", precision = 19, scale = 4) @Column(name = "depenses_reelles", precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal depensesReelles = BigDecimal.ZERO; private BigDecimal depensesReelles = BigDecimal.ZERO;
@Column(name = "date_lancement") @Column(name = "date_lancement")
private LocalDate dateLancement; private LocalDate dateLancement;
@Column(name = "date_fin_estimee") @Column(name = "date_fin_estimee")
private LocalDate dateFinEstimee; private LocalDate dateFinEstimee;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutProjetOng statut = StatutProjetOng.EN_ETUDE; private StatutProjetOng statut = StatutProjetOng.EN_ETUDE;
} }

View File

@@ -1,54 +1,54 @@
package dev.lions.unionflow.server.entity.registre; package dev.lions.unionflow.server.entity.registre;
import dev.lions.unionflow.server.api.enums.registre.StatutAgrement; import dev.lions.unionflow.server.api.enums.registre.StatutAgrement;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.time.LocalDate; import java.time.LocalDate;
@Entity @Entity
@Table(name = "agrements_professionnels", indexes = { @Table(name = "agrements_professionnels", indexes = {
@Index(name = "idx_agrement_membre", columnList = "membre_id"), @Index(name = "idx_agrement_membre", columnList = "membre_id"),
@Index(name = "idx_agrement_orga", columnList = "organisation_id") @Index(name = "idx_agrement_orga", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class AgrementProfessionnel extends BaseEntity { public class AgrementProfessionnel extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_id", nullable = false) @JoinColumn(name = "membre_id", nullable = false)
private Membre membre; private Membre membre;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@Column(name = "secteur_ordre", length = 150) @Column(name = "secteur_ordre", length = 150)
private String secteurOuOrdre; private String secteurOuOrdre;
@Column(name = "numero_licence", length = 100) @Column(name = "numero_licence", length = 100)
private String numeroLicenceOuRegistre; private String numeroLicenceOuRegistre;
@Column(name = "categorie_classement", length = 100) @Column(name = "categorie_classement", length = 100)
private String categorieClassement; private String categorieClassement;
@Column(name = "date_delivrance") @Column(name = "date_delivrance")
private LocalDate dateDelivrance; private LocalDate dateDelivrance;
@Column(name = "date_expiration") @Column(name = "date_expiration")
private LocalDate dateExpiration; private LocalDate dateExpiration;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutAgrement statut = StatutAgrement.PROVISOIRE; private StatutAgrement statut = StatutAgrement.PROVISOIRE;
} }

View File

@@ -1,73 +1,73 @@
package dev.lions.unionflow.server.entity.tontine; package dev.lions.unionflow.server.entity.tontine;
import dev.lions.unionflow.server.api.enums.tontine.FrequenceTour; import dev.lions.unionflow.server.api.enums.tontine.FrequenceTour;
import dev.lions.unionflow.server.api.enums.tontine.StatutTontine; import dev.lions.unionflow.server.api.enums.tontine.StatutTontine;
import dev.lions.unionflow.server.api.enums.tontine.TypeTontine; import dev.lions.unionflow.server.api.enums.tontine.TypeTontine;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@Entity @Entity
@Table(name = "tontines", indexes = { @Table(name = "tontines", indexes = {
@Index(name = "idx_tontine_organisation", columnList = "organisation_id") @Index(name = "idx_tontine_organisation", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class Tontine extends BaseEntity { public class Tontine extends BaseEntity {
@NotBlank @NotBlank
@Column(name = "nom", nullable = false, length = 150) @Column(name = "nom", nullable = false, length = 150)
private String nom; private String nom;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_tontine", nullable = false, length = 50) @Column(name = "type_tontine", nullable = false, length = 50)
private TypeTontine typeTontine; private TypeTontine typeTontine;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "frequence", nullable = false, length = 50) @Column(name = "frequence", nullable = false, length = 50)
private FrequenceTour frequence; private FrequenceTour frequence;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutTontine statut = StatutTontine.PLANIFIEE; private StatutTontine statut = StatutTontine.PLANIFIEE;
@Column(name = "date_debut_effective") @Column(name = "date_debut_effective")
private LocalDate dateDebutEffective; private LocalDate dateDebutEffective;
@Column(name = "date_fin_prevue") @Column(name = "date_fin_prevue")
private LocalDate dateFinPrevue; private LocalDate dateFinPrevue;
@Column(name = "montant_mise_tour", precision = 19, scale = 4) @Column(name = "montant_mise_tour", precision = 19, scale = 4)
private BigDecimal montantMiseParTour; private BigDecimal montantMiseParTour;
@Column(name = "limite_participants") @Column(name = "limite_participants")
private Integer limiteParticipants; private Integer limiteParticipants;
@OneToMany(mappedBy = "tontine", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "tontine", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("ordreTour ASC") @OrderBy("ordreTour ASC")
@Builder.Default @Builder.Default
private List<TourTontine> calendrierTours = new ArrayList<>(); private List<TourTontine> calendrierTours = new ArrayList<>();
} }

View File

@@ -1,56 +1,56 @@
package dev.lions.unionflow.server.entity.tontine; package dev.lions.unionflow.server.entity.tontine;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Membre; import dev.lions.unionflow.server.entity.Membre;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
@Entity @Entity
@Table(name = "tours_tontine", indexes = { @Table(name = "tours_tontine", indexes = {
@Index(name = "idx_tour_tontine", columnList = "tontine_id") @Index(name = "idx_tour_tontine", columnList = "tontine_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@ToString(exclude = { "tontine", "membreBeneficiaire" }) @ToString(exclude = { "tontine", "membreBeneficiaire" })
public class TourTontine extends BaseEntity { public class TourTontine extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tontine_id", nullable = false) @JoinColumn(name = "tontine_id", nullable = false)
private Tontine tontine; private Tontine tontine;
@NotNull @NotNull
@Column(name = "ordre_tour", nullable = false) @Column(name = "ordre_tour", nullable = false)
private Integer ordreTour; private Integer ordreTour;
@NotNull @NotNull
@Column(name = "date_ouverture_cotisations", nullable = false) @Column(name = "date_ouverture_cotisations", nullable = false)
private LocalDate dateOuvertureCotisations; private LocalDate dateOuvertureCotisations;
@Column(name = "date_tirage_remise") @Column(name = "date_tirage_remise")
private LocalDate dateTirageOuRemise; private LocalDate dateTirageOuRemise;
@NotNull @NotNull
@Column(name = "montant_cible", nullable = false, precision = 19, scale = 4) @Column(name = "montant_cible", nullable = false, precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal montantCible = BigDecimal.ZERO; private BigDecimal montantCible = BigDecimal.ZERO;
@NotNull @NotNull
@Column(name = "cagnotte_collectee", nullable = false, precision = 19, scale = 4) @Column(name = "cagnotte_collectee", nullable = false, precision = 19, scale = 4)
@Builder.Default @Builder.Default
private BigDecimal cagnotteCollectee = BigDecimal.ZERO; private BigDecimal cagnotteCollectee = BigDecimal.ZERO;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "membre_beneficiaire_id") @JoinColumn(name = "membre_beneficiaire_id")
private Membre membreBeneficiaire; private Membre membreBeneficiaire;
@Column(name = "statut_interne", length = 30) @Column(name = "statut_interne", length = 30)
private String statutInterne; private String statutInterne;
} }

View File

@@ -1,84 +1,84 @@
package dev.lions.unionflow.server.entity.vote; package dev.lions.unionflow.server.entity.vote;
import dev.lions.unionflow.server.api.enums.vote.ModeScrutin; import dev.lions.unionflow.server.api.enums.vote.ModeScrutin;
import dev.lions.unionflow.server.api.enums.vote.StatutVote; import dev.lions.unionflow.server.api.enums.vote.StatutVote;
import dev.lions.unionflow.server.api.enums.vote.TypeVote; import dev.lions.unionflow.server.api.enums.vote.TypeVote;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import dev.lions.unionflow.server.entity.Organisation; import dev.lions.unionflow.server.entity.Organisation;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.*; import lombok.*;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@Entity @Entity
@Table(name = "campagnes_vote", indexes = { @Table(name = "campagnes_vote", indexes = {
@Index(name = "idx_vote_orga", columnList = "organisation_id") @Index(name = "idx_vote_orga", columnList = "organisation_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class CampagneVote extends BaseEntity { public class CampagneVote extends BaseEntity {
@NotBlank @NotBlank
@Column(name = "titre", nullable = false, length = 200) @Column(name = "titre", nullable = false, length = 200)
private String titre; private String titre;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String descriptionOuResolution; private String descriptionOuResolution;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "organisation_id", nullable = false) @JoinColumn(name = "organisation_id", nullable = false)
private Organisation organisation; private Organisation organisation;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "type_vote", nullable = false, length = 50) @Column(name = "type_vote", nullable = false, length = 50)
private TypeVote typeVote; private TypeVote typeVote;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "mode_scrutin", nullable = false, length = 50) @Column(name = "mode_scrutin", nullable = false, length = 50)
private ModeScrutin modeScrutin; private ModeScrutin modeScrutin;
@NotNull @NotNull
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "statut", nullable = false, length = 50) @Column(name = "statut", nullable = false, length = 50)
@Builder.Default @Builder.Default
private StatutVote statut = StatutVote.BROUILLON; private StatutVote statut = StatutVote.BROUILLON;
@NotNull @NotNull
@Column(name = "date_ouverture", nullable = false) @Column(name = "date_ouverture", nullable = false)
private LocalDateTime dateOuverture; private LocalDateTime dateOuverture;
@NotNull @NotNull
@Column(name = "date_fermeture", nullable = false) @Column(name = "date_fermeture", nullable = false)
private LocalDateTime dateFermeture; private LocalDateTime dateFermeture;
@Column(name = "restreindre_membres_ajour", nullable = false) @Column(name = "restreindre_membres_ajour", nullable = false)
@Builder.Default @Builder.Default
private Boolean restreindreMembresAJour = true; private Boolean restreindreMembresAJour = true;
@Column(name = "autoriser_vote_blanc", nullable = false) @Column(name = "autoriser_vote_blanc", nullable = false)
@Builder.Default @Builder.Default
private Boolean autoriserVoteBlanc = true; private Boolean autoriserVoteBlanc = true;
@Column(name = "total_electeurs") @Column(name = "total_electeurs")
private Integer totalElecteursInscrits; private Integer totalElecteursInscrits;
@Column(name = "total_votants") @Column(name = "total_votants")
private Integer totalVotantsEffectifs; private Integer totalVotantsEffectifs;
@Column(name = "total_blancs_nuls") @Column(name = "total_blancs_nuls")
private Integer totalVotesBlancsOuNuls; private Integer totalVotesBlancsOuNuls;
@OneToMany(mappedBy = "campagneVote", cascade = CascadeType.ALL, orphanRemoval = true) @OneToMany(mappedBy = "campagneVote", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default @Builder.Default
private List<Candidat> candidats = new ArrayList<>(); private List<Candidat> candidats = new ArrayList<>();
} }

View File

@@ -1,45 +1,45 @@
package dev.lions.unionflow.server.entity.vote; package dev.lions.unionflow.server.entity.vote;
import dev.lions.unionflow.server.entity.BaseEntity; import dev.lions.unionflow.server.entity.BaseEntity;
import jakarta.persistence.*; import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import lombok.*; import lombok.*;
import java.math.BigDecimal; import java.math.BigDecimal;
@Entity @Entity
@Table(name = "candidats", indexes = { @Table(name = "candidats", indexes = {
@Index(name = "idx_candidat_campagne", columnList = "campagne_vote_id") @Index(name = "idx_candidat_campagne", columnList = "campagne_vote_id")
}) })
@Data @Data
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Builder @Builder
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@ToString(exclude = "campagneVote") @ToString(exclude = "campagneVote")
public class Candidat extends BaseEntity { public class Candidat extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "campagne_vote_id", nullable = false) @JoinColumn(name = "campagne_vote_id", nullable = false)
private CampagneVote campagneVote; private CampagneVote campagneVote;
@NotBlank @NotBlank
@Column(name = "nom_candidature", nullable = false, length = 150) @Column(name = "nom_candidature", nullable = false, length = 150)
private String nomCandidatureOuChoix; private String nomCandidatureOuChoix;
@Column(name = "membre_associe_id", length = 36) @Column(name = "membre_associe_id", length = 36)
private String membreIdAssocie; private String membreIdAssocie;
@Column(name = "profession_foi", columnDefinition = "TEXT") @Column(name = "profession_foi", columnDefinition = "TEXT")
private String professionDeFoi; private String professionDeFoi;
@Column(name = "photo_url", length = 500) @Column(name = "photo_url", length = 500)
private String photoUrl; private String photoUrl;
@Column(name = "nombre_voix") @Column(name = "nombre_voix")
@Builder.Default @Builder.Default
private Integer nombreDeVoix = 0; private Integer nombreDeVoix = 0;
@Column(name = "pourcentage", precision = 5, scale = 2) @Column(name = "pourcentage", precision = 5, scale = 2)
@Builder.Default @Builder.Default
private BigDecimal pourcentageObtenu = BigDecimal.ZERO; private BigDecimal pourcentageObtenu = BigDecimal.ZERO;
} }

View File

@@ -98,7 +98,9 @@ public class GlobalExceptionMapper implements ExceptionMapper<Throwable> {
return exception instanceof NotFoundException return exception instanceof NotFoundException
|| exception instanceof ForbiddenException || exception instanceof ForbiddenException
|| exception instanceof NotAuthorizedException || exception instanceof NotAuthorizedException
|| exception instanceof NotAllowedException; || exception instanceof NotAllowedException
|| exception instanceof IllegalArgumentException
|| exception instanceof IllegalStateException;
} }
private int determineStatusCode(Throwable exception) { private int determineStatusCode(Throwable exception) {

View File

@@ -1,40 +1,40 @@
package dev.lions.unionflow.server.exception; package dev.lions.unionflow.server.exception;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper; import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider; import jakarta.ws.rs.ext.Provider;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* Exception Mapper pour les erreurs de traitement JSON (parsing, format, etc.). * Exception Mapper pour les erreurs de traitement JSON (parsing, format, etc.).
* Retourne un 400 Bad Request avec un message détaillé. * Retourne un 400 Bad Request avec un message détaillé.
* *
* @author UnionFlow Team * @author UnionFlow Team
* @version 1.0 * @version 1.0
* @since 2026-03-19 * @since 2026-03-19
*/ */
@Slf4j @Slf4j
@Provider @Provider
public class JsonProcessingExceptionMapper implements ExceptionMapper<JsonProcessingException> { public class JsonProcessingExceptionMapper implements ExceptionMapper<JsonProcessingException> {
@Override @Override
public Response toResponse(JsonProcessingException exception) { public Response toResponse(JsonProcessingException exception) {
log.warn("JSON processing error: {}", exception.getMessage()); log.warn("JSON processing error: {}", exception.getMessage());
Map<String, Object> errorBody = new HashMap<>(); Map<String, Object> errorBody = new HashMap<>();
errorBody.put("message", "Erreur de traitement JSON"); errorBody.put("message", "Erreur de traitement JSON");
errorBody.put("details", exception.getOriginalMessage() != null errorBody.put("details", exception.getOriginalMessage() != null
? exception.getOriginalMessage() ? exception.getOriginalMessage()
: exception.getMessage()); : exception.getMessage());
errorBody.put("status", 400); errorBody.put("status", 400);
errorBody.put("timestamp", java.time.LocalDateTime.now().toString()); errorBody.put("timestamp", java.time.LocalDateTime.now().toString());
return Response.status(Response.Status.BAD_REQUEST) return Response.status(Response.Status.BAD_REQUEST)
.entity(errorBody) .entity(errorBody)
.build(); .build();
} }
} }

Some files were not shown because too many files have changed in this diff Show More