BUG-01: BudgetService.toResponse() — remplace doubleValue()>0 par compareTo(BigDecimal.ZERO)>0 (précision BigDecimal) ; ajoute 2 tests couvrant varianceRate=0 (totalPlanned=0) et varianceRate=-40% AUTH: MembreKeycloakSyncService.changerMotDePassePremierLogin() — élargit le catch de ForbiddenException vers WebApplicationException avec vérification du statut HTTP (le REST client MicroProfile ne garantit pas la sous-classe) DATA-01: MembreService.desactiverMembre() — décrémente nombreMembres sur toutes les orgs actives du membre et passe le statutMembre à DESACTIVE
334 lines
13 KiB
Java
334 lines
13 KiB
Java
package dev.lions.unionflow.server.service;
|
|
|
|
import dev.lions.unionflow.server.entity.Budget;
|
|
import dev.lions.unionflow.server.entity.BudgetLine;
|
|
import dev.lions.unionflow.server.entity.Organisation;
|
|
import dev.lions.unionflow.server.repository.BudgetRepository;
|
|
import dev.lions.unionflow.server.repository.OrganisationRepository;
|
|
import dev.lions.unionflow.server.api.dto.finance_workflow.request.CreateBudgetLineRequest;
|
|
import dev.lions.unionflow.server.api.dto.finance_workflow.request.CreateBudgetRequest;
|
|
import dev.lions.unionflow.server.api.dto.finance_workflow.response.BudgetLineResponse;
|
|
import dev.lions.unionflow.server.api.dto.finance_workflow.response.BudgetResponse;
|
|
import jakarta.enterprise.context.ApplicationScoped;
|
|
import jakarta.inject.Inject;
|
|
import jakarta.transaction.Transactional;
|
|
import jakarta.ws.rs.NotFoundException;
|
|
import jakarta.ws.rs.BadRequestException;
|
|
import org.eclipse.microprofile.jwt.JsonWebToken;
|
|
import org.jboss.logging.Logger;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDateTime;
|
|
import java.util.*;
|
|
import java.util.stream.Collectors;
|
|
|
|
/**
|
|
* Service métier pour la gestion des budgets
|
|
*
|
|
* @author UnionFlow Team
|
|
* @version 1.0
|
|
* @since 2026-03-13
|
|
*/
|
|
@ApplicationScoped
|
|
public class BudgetService {
|
|
|
|
private static final Logger LOG = Logger.getLogger(BudgetService.class);
|
|
|
|
@Inject
|
|
BudgetRepository budgetRepository;
|
|
|
|
@Inject
|
|
OrganisationRepository organisationRepository;
|
|
|
|
@Inject
|
|
JsonWebToken jwt;
|
|
|
|
/**
|
|
* Récupère tous les budgets d'une organisation avec filtres optionnels
|
|
*/
|
|
public List<BudgetResponse> getBudgets(UUID organizationId, String status, Integer year) {
|
|
LOG.infof("Récupération des budgets pour l'organisation %s (status=%s, year=%s)",
|
|
organizationId, status, year);
|
|
|
|
if (organizationId == null) {
|
|
throw new BadRequestException("L'ID de l'organisation est requis");
|
|
}
|
|
|
|
List<Budget> budgets = budgetRepository.findByOrganisationWithFilters(
|
|
organizationId, status, year);
|
|
|
|
return budgets.stream()
|
|
.map(this::toResponse)
|
|
.collect(Collectors.toList());
|
|
}
|
|
|
|
/**
|
|
* Récupère un budget par ID
|
|
*/
|
|
public BudgetResponse getBudgetById(UUID budgetId) {
|
|
LOG.infof("Récupération du budget %s", budgetId);
|
|
|
|
Budget budget = budgetRepository.findByIdOptional(budgetId)
|
|
.orElseThrow(() -> new NotFoundException("Budget non trouvé: " + budgetId));
|
|
|
|
return toResponse(budget);
|
|
}
|
|
|
|
/**
|
|
* Crée un nouveau budget
|
|
*/
|
|
@Transactional
|
|
public BudgetResponse createBudget(CreateBudgetRequest request) {
|
|
LOG.infof("Création d'un budget: %s", request.getName());
|
|
|
|
// Vérifier que l'organisation existe
|
|
Organisation organisation = organisationRepository.findByIdOptional(request.getOrganizationId())
|
|
.orElseThrow(() -> new NotFoundException("Organisation non trouvée: " + request.getOrganizationId()));
|
|
|
|
// Valider la période
|
|
if ("MONTHLY".equals(request.getPeriod()) && request.getMonth() == null) {
|
|
throw new BadRequestException("Le mois est requis pour un budget mensuel");
|
|
}
|
|
|
|
// Calculer les dates de début et fin
|
|
LocalDate startDate = calculateStartDate(request.getPeriod(), request.getYear(), request.getMonth());
|
|
LocalDate endDate = calculateEndDate(request.getPeriod(), request.getYear(), request.getMonth());
|
|
|
|
// Récupérer l'utilisateur courant
|
|
UUID userId = UUID.fromString(jwt.getClaim("sub"));
|
|
|
|
// Créer le budget
|
|
Budget budget = Budget.builder()
|
|
.name(request.getName())
|
|
.description(request.getDescription())
|
|
.organisation(organisation)
|
|
.period(request.getPeriod())
|
|
.year(request.getYear())
|
|
.month(request.getMonth())
|
|
.status("DRAFT")
|
|
.currency(request.getCurrency() != null ? request.getCurrency() : "XOF")
|
|
.createdById(userId)
|
|
.createdAtBudget(LocalDateTime.now())
|
|
.startDate(startDate)
|
|
.endDate(endDate)
|
|
.build();
|
|
|
|
// Ajouter les lignes budgétaires
|
|
for (CreateBudgetLineRequest lineRequest : request.getLines()) {
|
|
BudgetLine line = BudgetLine.builder()
|
|
.budget(budget)
|
|
.category(lineRequest.getCategory())
|
|
.name(lineRequest.getName())
|
|
.description(lineRequest.getDescription())
|
|
.amountPlanned(lineRequest.getAmountPlanned())
|
|
.amountRealized(BigDecimal.ZERO)
|
|
.notes(lineRequest.getNotes())
|
|
.build();
|
|
|
|
budget.addLine(line);
|
|
}
|
|
|
|
// Persister le budget
|
|
budgetRepository.persist(budget);
|
|
|
|
LOG.infof("Budget créé avec ID: %s", budget.getId());
|
|
|
|
return toResponse(budget);
|
|
}
|
|
|
|
/**
|
|
* Récupère le suivi budgétaire (tracking)
|
|
*/
|
|
public Map<String, Object> getBudgetTracking(UUID budgetId) {
|
|
LOG.infof("Récupération du suivi budgétaire pour %s", budgetId);
|
|
|
|
Budget budget = budgetRepository.findByIdOptional(budgetId)
|
|
.orElseThrow(() -> new NotFoundException("Budget non trouvé: " + budgetId));
|
|
|
|
Map<String, Object> tracking = new HashMap<>();
|
|
tracking.put("budgetId", budget.getId());
|
|
tracking.put("name", budget.getName());
|
|
tracking.put("status", budget.getStatus());
|
|
tracking.put("totalPlanned", budget.getTotalPlanned());
|
|
tracking.put("totalRealized", budget.getTotalRealized());
|
|
tracking.put("realizationRate", budget.getRealizationRate());
|
|
tracking.put("variance", budget.getVariance());
|
|
tracking.put("isOverBudget", budget.isOverBudget());
|
|
tracking.put("isActive", budget.isActive());
|
|
tracking.put("isCurrentPeriod", budget.isCurrentPeriod());
|
|
|
|
// Tracking par catégorie
|
|
Map<String, Map<String, Object>> byCategory = new HashMap<>();
|
|
for (BudgetLine line : budget.getLines()) {
|
|
String category = line.getCategory();
|
|
Map<String, Object> categoryData = byCategory.getOrDefault(category, new HashMap<>());
|
|
|
|
BigDecimal planned = (BigDecimal) categoryData.getOrDefault("planned", BigDecimal.ZERO);
|
|
BigDecimal realized = (BigDecimal) categoryData.getOrDefault("realized", BigDecimal.ZERO);
|
|
|
|
categoryData.put("planned", planned.add(line.getAmountPlanned()));
|
|
categoryData.put("realized", realized.add(line.getAmountRealized()));
|
|
|
|
byCategory.put(category, categoryData);
|
|
}
|
|
|
|
tracking.put("byCategory", byCategory);
|
|
|
|
// Lignes avec le plus grand écart
|
|
List<Map<String, Object>> topVariances = budget.getLines().stream()
|
|
.sorted((l1, l2) -> l2.getVariance().abs().compareTo(l1.getVariance().abs()))
|
|
.limit(5)
|
|
.map(line -> {
|
|
Map<String, Object> lineData = new HashMap<>();
|
|
lineData.put("name", line.getName());
|
|
lineData.put("category", line.getCategory());
|
|
lineData.put("planned", line.getAmountPlanned());
|
|
lineData.put("realized", line.getAmountRealized());
|
|
lineData.put("variance", line.getVariance());
|
|
lineData.put("isOverBudget", line.isOverBudget());
|
|
return lineData;
|
|
})
|
|
.collect(Collectors.toList());
|
|
|
|
tracking.put("topVariances", topVariances);
|
|
|
|
return tracking;
|
|
}
|
|
|
|
/**
|
|
* Calcule la date de début selon la période
|
|
*/
|
|
private LocalDate calculateStartDate(String period, int year, Integer month) {
|
|
return switch (period) {
|
|
case "MONTHLY" -> LocalDate.of(year, month != null ? month : 1, 1);
|
|
case "QUARTERLY" -> LocalDate.of(year, 1, 1); // Simplification: Q1
|
|
case "SEMIANNUAL" -> LocalDate.of(year, 1, 1); // Simplification: S1
|
|
case "ANNUAL" -> LocalDate.of(year, 1, 1);
|
|
default -> LocalDate.of(year, 1, 1);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calcule la date de fin selon la période
|
|
*/
|
|
private LocalDate calculateEndDate(String period, int year, Integer month) {
|
|
return switch (period) {
|
|
case "MONTHLY" -> {
|
|
int m = month != null ? month : 1;
|
|
yield LocalDate.of(year, m, 1).plusMonths(1).minusDays(1);
|
|
}
|
|
case "QUARTERLY" -> LocalDate.of(year, 3, 31); // Simplification: Q1
|
|
case "SEMIANNUAL" -> LocalDate.of(year, 6, 30); // Simplification: S1
|
|
case "ANNUAL" -> LocalDate.of(year, 12, 31);
|
|
default -> LocalDate.of(year, 12, 31);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Met à jour un budget
|
|
*/
|
|
@Transactional
|
|
public BudgetResponse updateBudget(UUID budgetId, Map<String, Object> updates) {
|
|
LOG.infof("Mise à jour du budget %s", budgetId);
|
|
|
|
Budget budget = budgetRepository.findByIdOptional(budgetId)
|
|
.orElseThrow(() -> new NotFoundException("Budget non trouvé: " + budgetId));
|
|
|
|
// Mise à jour des champs simples
|
|
if (updates.containsKey("name")) {
|
|
budget.setName((String) updates.get("name"));
|
|
}
|
|
if (updates.containsKey("description")) {
|
|
budget.setDescription((String) updates.get("description"));
|
|
}
|
|
if (updates.containsKey("status")) {
|
|
String newStatus = (String) updates.get("status");
|
|
if (!List.of("DRAFT", "ACTIVE", "CLOSED", "ARCHIVED").contains(newStatus)) {
|
|
throw new BadRequestException("Statut invalide: " + newStatus);
|
|
}
|
|
budget.setStatus(newStatus);
|
|
|
|
// Si on active le budget, enregistrer la date d'approbation
|
|
if ("ACTIVE".equals(newStatus) && budget.getApprovedAt() == null) {
|
|
budget.setApprovedAt(LocalDateTime.now());
|
|
budget.setApprovedById(UUID.fromString(jwt.getClaim("sub")));
|
|
}
|
|
}
|
|
|
|
budgetRepository.persist(budget);
|
|
LOG.infof("Budget %s mis à jour avec succès", budgetId);
|
|
|
|
return toResponse(budget);
|
|
}
|
|
|
|
/**
|
|
* Supprime un budget (soft delete)
|
|
*/
|
|
@Transactional
|
|
public void deleteBudget(UUID budgetId) {
|
|
LOG.infof("Suppression du budget %s", budgetId);
|
|
|
|
Budget budget = budgetRepository.findByIdOptional(budgetId)
|
|
.orElseThrow(() -> new NotFoundException("Budget non trouvé: " + budgetId));
|
|
|
|
// Soft delete: marquer comme inactif
|
|
budget.setActif(false);
|
|
budgetRepository.persist(budget);
|
|
|
|
LOG.infof("Budget %s supprimé avec succès", budgetId);
|
|
}
|
|
|
|
/**
|
|
* Convertit une entité Budget en DTO de réponse
|
|
*/
|
|
private BudgetResponse toResponse(Budget budget) {
|
|
List<BudgetLineResponse> linesResponse = budget.getLines().stream()
|
|
.map(line -> BudgetLineResponse.builder()
|
|
.id(line.getId())
|
|
.category(line.getCategory())
|
|
.name(line.getName())
|
|
.description(line.getDescription())
|
|
.amountPlanned(line.getAmountPlanned())
|
|
.amountRealized(line.getAmountRealized())
|
|
.notes(line.getNotes())
|
|
// Champs calculés
|
|
.realizationRate(line.getRealizationRate())
|
|
.variance(line.getVariance())
|
|
.isOverBudget(line.isOverBudget())
|
|
.build())
|
|
.collect(Collectors.toList());
|
|
|
|
return BudgetResponse.builder()
|
|
.id(budget.getId())
|
|
.name(budget.getName())
|
|
.description(budget.getDescription())
|
|
.organizationId(budget.getOrganisation().getId())
|
|
.period(budget.getPeriod())
|
|
.year(budget.getYear())
|
|
.month(budget.getMonth())
|
|
.status(budget.getStatus())
|
|
.lines(linesResponse)
|
|
.totalPlanned(budget.getTotalPlanned())
|
|
.totalRealized(budget.getTotalRealized())
|
|
.currency(budget.getCurrency())
|
|
.createdById(budget.getCreatedById())
|
|
.createdAt(budget.getCreatedAtBudget())
|
|
.approvedAt(budget.getApprovedAt())
|
|
.approvedById(budget.getApprovedById())
|
|
.startDate(budget.getStartDate())
|
|
.endDate(budget.getEndDate())
|
|
.metadata(budget.getMetadata())
|
|
// Champs calculés
|
|
.realizationRate(budget.getRealizationRate())
|
|
.variance(budget.getVariance())
|
|
.varianceRate(budget.getTotalPlanned().compareTo(BigDecimal.ZERO) > 0
|
|
? budget.getVariance().doubleValue() / budget.getTotalPlanned().doubleValue() * 100
|
|
: 0.0)
|
|
.isOverBudget(budget.isOverBudget())
|
|
.isActive(budget.isActive())
|
|
.isCurrentPeriod(budget.isCurrentPeriod())
|
|
.build();
|
|
}
|
|
}
|