package dev.lions.unionflow.server.entity; import jakarta.persistence.*; import jakarta.validation.constraints.*; import java.math.BigDecimal; 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é Approbation de Transaction * * Représente une approbation dans le workflow financier multi-niveaux. * Chaque transaction financière au-dessus d'un certain seuil nécessite une ou plusieurs approbations. * * @author UnionFlow Team * @version 1.0 * @since 2026-03-13 */ @Entity @Table(name = "transaction_approvals", indexes = { @Index(name = "idx_approval_transaction", columnList = "transaction_id"), @Index(name = "idx_approval_status", columnList = "status"), @Index(name = "idx_approval_requester", columnList = "requester_id"), @Index(name = "idx_approval_organisation", columnList = "organisation_id"), @Index(name = "idx_approval_created", columnList = "created_at"), @Index(name = "idx_approval_level", columnList = "required_level") }) @Data @NoArgsConstructor @AllArgsConstructor @Builder @EqualsAndHashCode(callSuper = true) public class TransactionApproval extends BaseEntity { /** ID de la transaction financière à approuver */ @NotNull @Column(name = "transaction_id", nullable = false) private UUID transactionId; /** Type de transaction (CONTRIBUTION, DEPOSIT, WITHDRAWAL, TRANSFER, SOLIDARITY, EVENT, OTHER) */ @NotBlank @Pattern(regexp = "^(CONTRIBUTION|DEPOSIT|WITHDRAWAL|TRANSFER|SOLIDARITY|EVENT|OTHER)$") @Column(name = "transaction_type", nullable = false, length = 20) private String transactionType; /** Montant de la transaction */ @NotNull @DecimalMin(value = "0.0", message = "Le montant doit être positif") @Digits(integer = 12, fraction = 2) @Column(name = "amount", nullable = false, precision = 14, scale = 2) private BigDecimal amount; /** 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 membre demandeur */ @NotNull @Column(name = "requester_id", nullable = false) private UUID requesterId; /** Nom complet du demandeur (cache pour performance) */ @NotBlank @Column(name = "requester_name", nullable = false, length = 200) private String requesterName; /** Organisation concernée (peut être null pour transactions globales) */ @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "organisation_id") private Organisation organisation; /** Niveau d'approbation requis (NONE, LEVEL1, LEVEL2, LEVEL3) */ @NotBlank @Pattern(regexp = "^(NONE|LEVEL1|LEVEL2|LEVEL3)$") @Column(name = "required_level", nullable = false, length = 10) private String requiredLevel; /** Statut de l'approbation (PENDING, APPROVED, VALIDATED, REJECTED, EXPIRED, CANCELLED) */ @NotBlank @Pattern(regexp = "^(PENDING|APPROVED|VALIDATED|REJECTED|EXPIRED|CANCELLED)$") @Builder.Default @Column(name = "status", nullable = false, length = 20) private String status = "PENDING"; /** Liste des actions d'approbateurs */ @OneToMany(mappedBy = "approval", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private List approvers = new ArrayList<>(); /** Raison du rejet (si status = REJECTED) */ @Size(max = 1000) @Column(name = "rejection_reason", length = 1000) private String rejectionReason; /** Date de création de la demande d'approbation */ @NotNull @Column(name = "created_at", nullable = false) private LocalDateTime createdAt; /** Date d'expiration (timeout) */ @Column(name = "expires_at") private LocalDateTime expiresAt; /** Date de completion (approbation finale ou rejet) */ @Column(name = "completed_at") private LocalDateTime completedAt; /** Métadonnées additionnelles (JSON) */ @Column(name = "metadata", columnDefinition = "TEXT") private String metadata; @PrePersist protected void onCreate() { super.onCreate(); if (createdAt == null) { createdAt = LocalDateTime.now(); } if (currency == null) { currency = "XOF"; } if (status == null) { status = "PENDING"; } // Expiration par défaut: 7 jours if (expiresAt == null) { expiresAt = createdAt.plusDays(7); } } /** Méthode métier pour ajouter une action d'approbateur */ public void addApproverAction(ApproverAction action) { approvers.add(action); action.setApproval(this); } /** Méthode métier pour compter les approbations */ public long countApprovals() { return approvers.stream() .filter(a -> "APPROVED".equals(a.getDecision())) .count(); } /** Méthode métier pour obtenir le nombre d'approbations requises */ public int getRequiredApprovals() { return switch (requiredLevel) { case "NONE" -> 0; case "LEVEL1" -> 1; case "LEVEL2" -> 2; case "LEVEL3" -> 3; default -> 0; }; } /** Méthode métier pour vérifier si toutes les approbations sont reçues */ public boolean hasAllApprovals() { return countApprovals() >= getRequiredApprovals(); } /** Méthode métier pour vérifier si l'approbation est expirée */ public boolean isExpired() { return expiresAt != null && LocalDateTime.now().isAfter(expiresAt); } /** Méthode métier pour vérifier si l'approbation est en attente */ public boolean isPending() { return "PENDING".equals(status); } /** Méthode métier pour vérifier si l'approbation est complétée */ public boolean isCompleted() { return "VALIDATED".equals(status) || "REJECTED".equals(status) || "CANCELLED".equals(status); } }