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 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 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 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 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> byCategory = new HashMap<>(); for (BudgetLine line : budget.getLines()) { String category = line.getCategory(); Map 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> topVariances = budget.getLines().stream() .sorted((l1, l2) -> l2.getVariance().abs().compareTo(l1.getVariance().abs())) .limit(5) .map(line -> { Map 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 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 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(); } }