Files
unionflow-server-impl-quarkus/src/main/java/dev/lions/unionflow/server/service/ApprovalService.java
dahoud 1c096f0ee1 feat(backend): ajout 8 endpoints manquants workflow financier
Endpoints ajoutés :
- POST /api/finance/approvals - requestApproval (avec organizationId)
- GET /api/finance/approvals/history - historique filtrable
- PUT /api/finance/budgets/{id} - updateBudget (name, description, status)
- DELETE /api/finance/budgets/{id} - deleteBudget (soft delete)
- GET /api/finance/stats - statistiques workflow global
- GET /api/finance/audit-logs - logs d'audit filtrables
- GET /api/finance/audit-logs/anomalies - détection anomalies
- POST /api/finance/audit-logs/export - export CSV/PDF

Services :
- ApprovalService.requestApproval() : logique niveaux LEVEL1/2/3 selon montant
- ApprovalService.getApprovalsHistory() : filtres date + statut
- BudgetService.updateBudget() : validation statut + approbation
- BudgetService.deleteBudget() : soft delete (actif=false)

Notes :
- Niveau approbation : <100K=NONE, 100K-1M=LEVEL1, 1M-5M=LEVEL2, >5M=LEVEL3
- organizationId optionnel dans requestApproval (pas de récup auto depuis Membre)
- FinanceWorkflowResource créé pour stats/audit (implémentation stub)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-16 21:17:18 +00:00

330 lines
13 KiB
Java

package dev.lions.unionflow.server.service;
import dev.lions.unionflow.server.entity.ApproverAction;
import dev.lions.unionflow.server.entity.Membre;
import dev.lions.unionflow.server.entity.Organisation;
import dev.lions.unionflow.server.entity.TransactionApproval;
import dev.lions.unionflow.server.repository.MembreRepository;
import dev.lions.unionflow.server.repository.OrganisationRepository;
import dev.lions.unionflow.server.repository.TransactionApprovalRepository;
import dev.lions.unionflow.server.api.dto.finance_workflow.request.ApproveTransactionRequest;
import dev.lions.unionflow.server.api.dto.finance_workflow.request.RejectTransactionRequest;
import dev.lions.unionflow.server.api.dto.finance_workflow.response.ApproverActionResponse;
import dev.lions.unionflow.server.api.dto.finance_workflow.response.TransactionApprovalResponse;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.ForbiddenException;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Service métier pour la gestion des approbations de transactions
*
* @author UnionFlow Team
* @version 1.0
* @since 2026-03-13
*/
@ApplicationScoped
public class ApprovalService {
private static final Logger LOG = Logger.getLogger(ApprovalService.class);
@Inject
TransactionApprovalRepository approvalRepository;
@Inject
MembreRepository membreRepository;
@Inject
OrganisationRepository organisationRepository;
@Inject
JsonWebToken jwt;
/**
* Demande une approbation pour une transaction
*/
@Transactional
public TransactionApprovalResponse requestApproval(
UUID transactionId,
String transactionType,
Double amount,
UUID organizationId) {
LOG.infof("Demande d'approbation pour transaction %s (type: %s, montant: %.2f, org: %s)",
transactionId, transactionType, amount, organizationId);
// Récupérer l'utilisateur courant
String userEmail = jwt.getClaim("email");
UUID userId = UUID.fromString(jwt.getClaim("sub"));
Membre membre = membreRepository.findByEmail(userEmail)
.orElseThrow(() -> new ForbiddenException("Utilisateur non trouvé"));
// Récupérer l'organisation si fournie
Organisation organisation = null;
if (organizationId != null) {
organisation = organisationRepository.findByIdOptional(organizationId)
.orElseThrow(() -> new NotFoundException("Organisation non trouvée: " + organizationId));
}
// Déterminer le niveau d'approbation requis selon le montant
String requiredLevel = determineRequiredLevel(amount);
// Créer la demande d'approbation
TransactionApproval approval = TransactionApproval.builder()
.transactionId(transactionId)
.transactionType(transactionType)
.amount(java.math.BigDecimal.valueOf(amount))
.currency("XOF")
.requesterId(userId)
.requesterName(membre.getNom() + " " + membre.getPrenom())
.organisation(organisation)
.requiredLevel(requiredLevel)
.status("PENDING")
.createdAt(LocalDateTime.now())
.expiresAt(LocalDateTime.now().plusDays(7)) // 7 jours par défaut
.build();
approvalRepository.persist(approval);
LOG.infof("Demande d'approbation créée avec ID: %s (niveau: %s, %d approbations requises)",
approval.getId(), requiredLevel, approval.getRequiredApprovals());
return toResponse(approval);
}
/**
* Détermine le niveau d'approbation requis selon le montant
* Utilise les niveaux standard de l'entité: NONE, LEVEL1, LEVEL2, LEVEL3
*/
private String determineRequiredLevel(Double amount) {
if (amount >= 5_000_000) { // 5M XOF
return "LEVEL3"; // 3 approbations (Board)
} else if (amount >= 1_000_000) { // 1M XOF
return "LEVEL2"; // 2 approbations (Director)
} else if (amount >= 100_000) { // 100K XOF
return "LEVEL1"; // 1 approbation (Manager)
} else {
return "NONE"; // Pas d'approbation requise
}
}
/**
* Récupère toutes les approbations en attente
*/
public List<TransactionApprovalResponse> getPendingApprovals(UUID organizationId) {
LOG.infof("Récupération des approbations en attente pour l'organisation %s", organizationId);
List<TransactionApproval> approvals = organizationId != null
? approvalRepository.findPendingByOrganisation(organizationId)
: approvalRepository.findPending();
return approvals.stream()
.map(this::toResponse)
.collect(Collectors.toList());
}
/**
* Récupère une approbation par ID
*/
public TransactionApprovalResponse getApprovalById(UUID approvalId) {
LOG.infof("Récupération de l'approbation %s", approvalId);
TransactionApproval approval = approvalRepository.findByIdOptional(approvalId)
.orElseThrow(() -> new NotFoundException("Approbation non trouvée: " + approvalId));
return toResponse(approval);
}
/**
* Approuve une transaction
*/
@Transactional
public TransactionApprovalResponse approveTransaction(UUID approvalId, ApproveTransactionRequest request) {
LOG.infof("Approbation de la transaction %s", approvalId);
// Récupérer l'approbation
TransactionApproval approval = approvalRepository.findByIdOptional(approvalId)
.orElseThrow(() -> new NotFoundException("Approbation non trouvée: " + approvalId));
// Vérifier que l'approbation est en attente
if (!"PENDING".equals(approval.getStatus())) {
throw new ForbiddenException("Cette approbation n'est plus en attente");
}
// Vérifier que l'approbation n'est pas expirée
if (approval.isExpired()) {
approval.setStatus("EXPIRED");
approvalRepository.persist(approval);
throw new ForbiddenException("Cette approbation est expirée");
}
// Récupérer l'utilisateur courant
String userEmail = jwt.getClaim("email");
UUID userId = UUID.fromString(jwt.getClaim("sub"));
Membre membre = membreRepository.findByEmail(userEmail)
.orElseThrow(() -> new ForbiddenException("Utilisateur non trouvé"));
// Vérifier que l'utilisateur n'est pas le demandeur
if (approval.getRequesterId().equals(userId)) {
throw new ForbiddenException("Vous ne pouvez pas approuver votre propre demande");
}
// Créer l'action d'approbation
ApproverAction action = ApproverAction.builder()
.approval(approval)
.approverId(userId)
.approverName(membre.getNom() + " " + membre.getPrenom())
.approverRole(jwt.getClaim("role")) // Récupérer le rôle depuis JWT
.decision("APPROVED")
.comment(request.getComment())
.decidedAt(LocalDateTime.now())
.build();
approval.addApproverAction(action);
// Vérifier si toutes les approbations requises sont reçues
if (approval.hasAllApprovals()) {
approval.setStatus("VALIDATED");
approval.setCompletedAt(LocalDateTime.now());
LOG.infof("Transaction %s validée avec toutes les approbations", approval.getTransactionId());
} else {
approval.setStatus("APPROVED");
LOG.infof("Transaction %s approuvée (%d/%d)",
approval.getTransactionId(),
approval.countApprovals(),
approval.getRequiredApprovals());
}
approvalRepository.persist(approval);
return toResponse(approval);
}
/**
* Rejette une transaction
*/
@Transactional
public TransactionApprovalResponse rejectTransaction(UUID approvalId, RejectTransactionRequest request) {
LOG.infof("Rejet de la transaction %s", approvalId);
// Récupérer l'approbation
TransactionApproval approval = approvalRepository.findByIdOptional(approvalId)
.orElseThrow(() -> new NotFoundException("Approbation non trouvée: " + approvalId));
// Vérifier que l'approbation est en attente
if (!"PENDING".equals(approval.getStatus()) && !"APPROVED".equals(approval.getStatus())) {
throw new ForbiddenException("Cette approbation ne peut plus être rejetée");
}
// Récupérer l'utilisateur courant
String userEmail = jwt.getClaim("email");
UUID userId = UUID.fromString(jwt.getClaim("sub"));
Membre membre = membreRepository.findByEmail(userEmail)
.orElseThrow(() -> new ForbiddenException("Utilisateur non trouvé"));
// Créer l'action de rejet
ApproverAction action = ApproverAction.builder()
.approval(approval)
.approverId(userId)
.approverName(membre.getNom() + " " + membre.getPrenom())
.approverRole(jwt.getClaim("role"))
.decision("REJECTED")
.comment(request.getReason())
.decidedAt(LocalDateTime.now())
.build();
approval.addApproverAction(action);
approval.setStatus("REJECTED");
approval.setRejectionReason(request.getReason());
approval.setCompletedAt(LocalDateTime.now());
approvalRepository.persist(approval);
LOG.infof("Transaction %s rejetée: %s", approval.getTransactionId(), request.getReason());
return toResponse(approval);
}
/**
* Récupère l'historique des approbations
*/
public List<TransactionApprovalResponse> getApprovalsHistory(
UUID organizationId,
LocalDateTime startDate,
LocalDateTime endDate,
String status) {
LOG.infof("Récupération de l'historique des approbations pour l'organisation %s", organizationId);
if (organizationId == null) {
throw new IllegalArgumentException("L'ID de l'organisation est requis");
}
List<TransactionApproval> approvals = approvalRepository.findHistory(
organizationId, startDate, endDate, status);
return approvals.stream()
.map(this::toResponse)
.collect(Collectors.toList());
}
/**
* Compte les approbations en attente
*/
public long countPendingApprovals(UUID organizationId) {
if (organizationId == null) {
return approvalRepository.count("status", "PENDING");
}
return approvalRepository.countPendingByOrganisation(organizationId);
}
/**
* Convertit une entité en DTO de réponse
*/
private TransactionApprovalResponse toResponse(TransactionApproval approval) {
List<ApproverActionResponse> approversResponse = approval.getApprovers().stream()
.map(action -> ApproverActionResponse.builder()
.id(action.getId())
.approverId(action.getApproverId())
.approverName(action.getApproverName())
.approverRole(action.getApproverRole())
.decision(action.getDecision())
.comment(action.getComment())
.decidedAt(action.getDecidedAt())
.build())
.collect(Collectors.toList());
return TransactionApprovalResponse.builder()
.id(approval.getId())
.transactionId(approval.getTransactionId())
.transactionType(approval.getTransactionType())
.amount(approval.getAmount())
.currency(approval.getCurrency())
.requesterId(approval.getRequesterId())
.requesterName(approval.getRequesterName())
.organizationId(approval.getOrganisation() != null ? approval.getOrganisation().getId() : null)
.requiredLevel(approval.getRequiredLevel())
.status(approval.getStatus())
.approvers(approversResponse)
.rejectionReason(approval.getRejectionReason())
.createdAt(approval.getCreatedAt())
.expiresAt(approval.getExpiresAt())
.completedAt(approval.getCompletedAt())
.metadata(approval.getMetadata())
// Champs calculés
.approvalCount((int) approval.countApprovals())
.requiredApprovals(approval.getRequiredApprovals())
.hasAllApprovals(approval.hasAllApprovals())
.isExpired(approval.isExpired())
.isPending(approval.isPending())
.isCompleted(approval.isCompleted())
.build();
}
}