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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
7ce7353e7a
commit
048a4ecfde
+9
@@ -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 {
|
||||
}
|
||||
+2
@@ -29,4 +29,6 @@ public class ElectionConfigData {
|
||||
private Long totalNominations;
|
||||
private Long approvedNominations;
|
||||
private Long pendingNominations;
|
||||
private Integer durationYears;
|
||||
private LocalDate durationEndDate;
|
||||
}
|
||||
|
||||
+1
@@ -22,5 +22,6 @@ public class ElectionConfigRequest {
|
||||
private LocalDate electionDate;
|
||||
private LocalDate resultDate;
|
||||
private String remarks;
|
||||
private Integer durationYears;
|
||||
private List<ElectionPostRequest> posts;
|
||||
}
|
||||
|
||||
+3
-2
@@ -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<DirectorApp
|
||||
boolean existsByElectionIdAndCandidateId(Long electionId, Long candidateId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE DirectorAppointment d SET d.status = 'TENURE_ENDED' WHERE d.status = 'ACTIVE' AND d.tenureEndDate < :today")
|
||||
int expireByTenureDate(LocalDate today);
|
||||
@Query("UPDATE DirectorAppointment d SET d.status = org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus.TENURE_ENDED WHERE d.status = org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus.ACTIVE AND d.tenureEndDate < :today")
|
||||
int expireByTenureDate(@Param("today") LocalDate today);
|
||||
}
|
||||
|
||||
+7
-3
@@ -49,12 +49,15 @@ public class ElectionConfig extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
@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<ElectionPost> 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<Long>
|
||||
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() {
|
||||
|
||||
+4
@@ -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<ElectionConfig, Long> {
|
||||
@@ -34,4 +35,7 @@ public interface ElectionConfigRepository extends JpaRepository<ElectionConfig,
|
||||
Optional<ElectionConfig> findByIdAndIsActiveTrue(Long id);
|
||||
|
||||
boolean existsByStatusIn(Set<ElectionStatus> 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<ElectionConfig> findFirstWithActiveDuration(@Param("today") LocalDate today);
|
||||
}
|
||||
|
||||
+14
@@ -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.");
|
||||
}
|
||||
}
|
||||
+50
-4
@@ -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())) {
|
||||
|
||||
+6
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -375,4 +375,5 @@
|
||||
<include file="parts/0345_election_candidate_manual_vote_count_drop_default.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0346_election_audit_log_description_to_text.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0347_election_director_appointment.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0348_election_config_duration_years.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.6.xsd">
|
||||
|
||||
<changeSet id="0348-add-duration-years-to-election-config" author="lincpay">
|
||||
<addColumn tableName="m_election_config">
|
||||
<column name="duration_years" type="INT">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user