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