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>
330 lines
13 KiB
Java
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();
|
|
}
|
|
}
|