From 048a4ecfded6c8e95c04bc4ea6c2ada089ac16ec Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Fri, 3 Jul 2026 12:25:43 +0530 Subject: [PATCH] feat(election): election duration blocking, director tenure auto-fill, audit logging, and scheduler - Add durationYears to ElectionConfig with Liquibase migration (0348) - Block new election creation when previous election duration is still active (PostgreSQL-compatible native query) - Auto-override director tenureEndDate from election durationEndDate on appoint and update - Add ElectionSchedulerConfiguration to enable @Scheduled support - Fix JPQL EclipseLink enum compatibility in expireByTenureDate query - Add full audit logging to DirectorAppointmentServiceImpl (appoint, update, remove, auto-expire) Co-Authored-By: Claude Sonnet 4.6 --- .../ElectionSchedulerConfiguration.java | 9 ++++ .../election/data/ElectionConfigData.java | 2 + .../election/data/ElectionConfigRequest.java | 1 + .../domain/DirectorAppointmentRepository.java | 5 +- .../election/domain/ElectionConfig.java | 10 ++-- .../domain/ElectionConfigRepository.java | 4 ++ .../ElectionDurationActiveException.java | 14 +++++ .../DirectorAppointmentServiceImpl.java | 54 +++++++++++++++++-- .../ElectionReadPlatformServiceImpl.java | 6 +++ .../ElectionWritePlatformServiceImpl.java | 10 +++- .../db/changelog/tenant/changelog-tenant.xml | 1 + .../0348_election_config_duration_years.xml | 16 ++++++ 12 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/config/ElectionSchedulerConfiguration.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDurationActiveException.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_election_config_duration_years.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/config/ElectionSchedulerConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/config/ElectionSchedulerConfiguration.java new file mode 100644 index 000000000..46422c3a9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/config/ElectionSchedulerConfiguration.java @@ -0,0 +1,9 @@ +package org.apache.fineract.portfolio.election.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class ElectionSchedulerConfiguration { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigData.java index 42fbed8ea..e9527e49d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigData.java @@ -29,4 +29,6 @@ public class ElectionConfigData { private Long totalNominations; private Long approvedNominations; private Long pendingNominations; + private Integer durationYears; + private LocalDate durationEndDate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigRequest.java index 7858fefd9..64fdd3923 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionConfigRequest.java @@ -22,5 +22,6 @@ public class ElectionConfigRequest { private LocalDate electionDate; private LocalDate resultDate; private String remarks; + private Integer durationYears; private List posts; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java index b64de7eab..4e61825f8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -26,6 +27,6 @@ public interface DirectorAppointmentRepository extends JpaRepository @Column(name = "remarks", columnDefinition = "TEXT") private String remarks; + @Column(name = "duration_years") + private Integer durationYears; + @OneToMany(mappedBy = "electionConfig", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) private List posts = new ArrayList<>(); private ElectionConfig(String title, String description, ElectionType electionType, LocalDate nominationStartDate, LocalDate nominationEndDate, - LocalDate electionDate, LocalDate resultDate) { + LocalDate electionDate, LocalDate resultDate, Integer durationYears) { this.title = title; this.description = description; this.electionType = electionType; @@ -62,15 +65,16 @@ public class ElectionConfig extends AbstractAuditableWithUTCDateTimeCustom this.nominationEndDate = nominationEndDate; this.electionDate = electionDate; this.resultDate = resultDate; + this.durationYears = durationYears; this.status = ElectionStatus.DRAFT; this.isActive = true; } public static ElectionConfig create(String title, String description, ElectionType electionType, LocalDate nominationStartDate, LocalDate nominationEndDate, - LocalDate electionDate, LocalDate resultDate) { + LocalDate electionDate, LocalDate resultDate, Integer durationYears) { return new ElectionConfig(title, description, electionType, nominationStartDate, - nominationEndDate, electionDate, resultDate); + nominationEndDate, electionDate, resultDate, durationYears); } public boolean isNominationOpen() { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java index 6f8b040f1..b7dcd33a5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; import java.util.Set; +import java.time.LocalDate; @Repository public interface ElectionConfigRepository extends JpaRepository { @@ -34,4 +35,7 @@ public interface ElectionConfigRepository extends JpaRepository findByIdAndIsActiveTrue(Long id); boolean existsByStatusIn(Set statuses); + + @Query(value = "SELECT * FROM m_election_config WHERE status = 'RESULT_DECLARED' AND duration_years IS NOT NULL AND election_date + (duration_years || ' years')::interval > :today LIMIT 1", nativeQuery = true) + Optional findFirstWithActiveDuration(@Param("today") LocalDate today); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDurationActiveException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDurationActiveException.java new file mode 100644 index 000000000..be1db7944 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/ElectionDurationActiveException.java @@ -0,0 +1,14 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +import java.time.LocalDate; + +public class ElectionDurationActiveException extends AbstractPlatformDomainRuleException { + + public ElectionDurationActiveException(LocalDate expiryDate) { + super("error.msg.election.duration.active", + "The previous election's tenure period is still active until " + expiryDate + + ". A new election cannot be created until this duration expires."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java index 3255c9479..0d0af12fd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java @@ -9,6 +9,8 @@ import org.apache.fineract.portfolio.election.data.PendingDirectorData; import org.apache.fineract.portfolio.election.domain.DirectorAppointment; import org.apache.fineract.portfolio.election.domain.DirectorAppointmentRepository; import org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus; +import org.apache.fineract.portfolio.election.domain.ElectionAuditAction; +import org.apache.fineract.portfolio.election.domain.ElectionConfigRepository; import org.apache.fineract.portfolio.election.domain.ElectionResultRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -31,12 +33,19 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic private final DirectorAppointmentRepository repository; private final ElectionResultRepository resultRepository; + private final ElectionConfigRepository electionConfigRepository; + private final ElectionAuditService auditService; @Override @Transactional public Long appoint(DirectorAppointmentRequest req) { + overrideTenureEndFromElection(req); validateTenureDates(req); if (repository.existsByElectionIdAndCandidateId(req.getElectionId(), req.getCandidateId())) { + auditService.log(ElectionAuditAction.APPOINT_DIRECTOR_FAILED, "DIRECTOR_APPOINTMENT", null, + req.getElectionId(), + "Director appointment failed — candidate already appointed", + "candidateId=" + req.getCandidateId() + ", electionId=" + req.getElectionId(), null); throw new IllegalStateException( "Candidate " + req.getCandidateId() + " is already appointed for election " + req.getElectionId()); } @@ -44,7 +53,15 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic req.getElectionId(), req.getCandidateId(), req.getCandidateName(), req.getPostName()); applyFields(d, req); - return repository.saveAndFlush(d).getId(); + Long id = repository.saveAndFlush(d).getId(); + auditService.log(ElectionAuditAction.APPOINT_DIRECTOR, "DIRECTOR_APPOINTMENT", id, + req.getElectionId(), + req.getCandidateName() + " appointed as " + req.getDesignation(), + "designation=" + req.getDesignation() + + ", tenureStart=" + req.getTenureStartDate() + + ", tenureEnd=" + req.getTenureEndDate() + + ", resolutionNo=" + req.getResolutionNo(), null); + return id; } @Override @@ -67,10 +84,26 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic @Override @Transactional public DirectorAppointmentData update(Long id, DirectorAppointmentRequest req) { + DirectorAppointment existing = findOrThrow(id); + if (req.getElectionId() == null) req.setElectionId(existing.getElectionId()); + overrideTenureEndFromElection(req); validateTenureDates(req); - DirectorAppointment d = findOrThrow(id); - applyFields(d, req); - return toData(repository.save(d)); + boolean isRemoval = "REMOVED".equalsIgnoreCase(req.getStatus()) + && existing.getStatus() != DirectorAppointmentStatus.REMOVED; + applyFields(existing, req); + DirectorAppointmentData result = toData(repository.save(existing)); + ElectionAuditAction action = isRemoval + ? ElectionAuditAction.REMOVE_DIRECTOR_APPOINTMENT + : ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT; + String description = isRemoval + ? existing.getCandidateName() + " marked as Removed" + : existing.getCandidateName() + " appointment updated"; + auditService.log(action, "DIRECTOR_APPOINTMENT", id, existing.getElectionId(), + description, + "status=" + req.getStatus() + + ", designation=" + req.getDesignation() + + ", remarks=" + req.getRemarks(), null); + return result; } @Override @@ -92,6 +125,9 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic int updated = repository.expireByTenureDate(LocalDate.now()); if (updated > 0) { log.info("Auto-expired {} director appointment(s) whose tenure ended.", updated); + auditService.log(ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT, "DIRECTOR_APPOINTMENT", null, null, + "Scheduler auto-expired " + updated + " director appointment(s)", + "expiredCount=" + updated + ", date=" + LocalDate.now(), null); } } @@ -143,6 +179,16 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic if (req.getPostName() != null) d.setPostName(req.getPostName()); } + private void overrideTenureEndFromElection(DirectorAppointmentRequest req) { + if (req.getElectionId() == null) return; + electionConfigRepository.findById(req.getElectionId()).ifPresent(election -> { + if (election.getDurationYears() != null && election.getElectionDate() != null) { + LocalDate durationEndDate = election.getElectionDate().plusYears(election.getDurationYears()); + req.setTenureEndDate(durationEndDate); + } + }); + } + private void validateTenureDates(DirectorAppointmentRequest req) { if (req.getTenureStartDate() != null && req.getTenureEndDate() != null && !req.getTenureEndDate().isAfter(req.getTenureStartDate())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java index faee556af..ac72a3dd5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java @@ -291,6 +291,10 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ } private ElectionConfigData toConfigData(ElectionConfig election) { + LocalDate durationEndDate = null; + if (election.getElectionDate() != null && election.getDurationYears() != null) { + durationEndDate = election.getElectionDate().plusYears(election.getDurationYears()); + } return ElectionConfigData.builder() .id(election.getId()) .title(election.getTitle()) @@ -303,6 +307,8 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ .status(election.getStatus() != null ? election.getStatus().name() : null) .isActive(election.getIsActive()) .remarks(election.getRemarks()) + .durationYears(election.getDurationYears()) + .durationEndDate(durationEndDate) .build(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java index 231bd03f7..618ccd76d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java @@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository; import org.apache.fineract.portfolio.election.domain.NominationStatus; import org.apache.fineract.portfolio.election.exception.ElectionAlreadyOngoingException; import org.apache.fineract.portfolio.election.exception.ElectionDateViolationException; +import org.apache.fineract.portfolio.election.exception.ElectionDurationActiveException; import org.apache.fineract.portfolio.election.exception.ElectionNotFoundException; import org.apache.fineract.portfolio.election.exception.ElectionNominationNotFoundException; import org.apache.fineract.portfolio.election.exception.ElectionNotActiveException; @@ -92,6 +93,11 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe throw new ElectionAlreadyOngoingException(); } + electionConfigRepository.findFirstWithActiveDuration(LocalDate.now()).ifPresent(prev -> { + LocalDate expiryDate = prev.getElectionDate().plusYears(prev.getDurationYears()); + throw new ElectionDurationActiveException(expiryDate); + }); + validateDateOrder(request.getNominationStartDate(), request.getNominationEndDate(), request.getElectionDate(), request.getResultDate()); @@ -102,7 +108,8 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe request.getNominationStartDate(), request.getNominationEndDate(), request.getElectionDate(), - request.getResultDate() + request.getResultDate(), + request.getDurationYears() ); ElectionConfig saved = electionConfigRepository.saveAndFlush(election); @@ -131,6 +138,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe if (request.getNominationEndDate() != null) election.setNominationEndDate(request.getNominationEndDate()); if (request.getElectionDate() != null) election.setElectionDate(request.getElectionDate()); if (request.getResultDate() != null) election.setResultDate(request.getResultDate()); + if (request.getDurationYears() != null) election.setDurationYears(request.getDurationYears()); electionConfigRepository.save(election); } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 4825d7549..6ba2ec7bf 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -375,4 +375,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_election_config_duration_years.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_election_config_duration_years.xml new file mode 100644 index 000000000..acdcdabcd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0348_election_config_duration_years.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + +