diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java new file mode 100644 index 000000000..9ff50fc6b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java @@ -0,0 +1,121 @@ +package org.apache.fineract.portfolio.election.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.election.data.DirectorAppointmentRequest; +import org.apache.fineract.portfolio.election.domain.ElectionAuditAction; +import org.apache.fineract.portfolio.election.service.DirectorAppointmentService; +import org.apache.fineract.portfolio.election.service.ElectionAuditService; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Path("/v1/election/directors") +@Tag(name = "Board of Directors", description = "Manage elected director appointments") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class DirectorAppointmentApiResource { + + private final DirectorAppointmentService service; + private final ElectionAuditService auditService; + + @GET + @Path("/pending") + @Operation(summary = "Won candidates not yet appointed as directors") + public Response pending() { + return Response.ok(service.retrievePending()).build(); + } + + @GET + @Operation(summary = "List all director appointments (paginated)") + public Response list( + @QueryParam("status") String status, + @QueryParam("electionId") Long electionId, + @QueryParam("page") @DefaultValue("0") int page, + @QueryParam("size") @DefaultValue("20") int size) { + return Response.ok(service.retrieveAll(status, electionId, page, size)).build(); + } + + @GET + @Path("/{id}") + @Operation(summary = "Get director appointment detail") + public Response get(@PathParam("id") Long id) { + return Response.ok(service.retrieve(id)).build(); + } + + @POST + @Operation(summary = "Appoint a director") + public Response appoint(@Valid DirectorAppointmentRequest request, + @Context HttpServletRequest httpRequest) { + Long id; + try { + id = service.appoint(request); + } catch (RuntimeException e) { + auditService.logInNewTransaction(ElectionAuditAction.APPOINT_DIRECTOR_FAILED, + "DIRECTOR", null, request.getElectionId(), + "Director appointment failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + auditService.log(ElectionAuditAction.APPOINT_DIRECTOR, + "DIRECTOR", id, request.getElectionId(), + "Director appointed: " + request.getCandidateName() + " as " + request.getDesignation(), + "candidateId=" + request.getCandidateId() + ", post=" + request.getPostName(), + clientIp(httpRequest)); + return Response.ok(Map.of("resourceId", id)).build(); + } + + @PUT + @Path("/{id}") + @Operation(summary = "Update director appointment details") + public Response update(@PathParam("id") Long id, @Valid DirectorAppointmentRequest request, + @Context HttpServletRequest httpRequest) { + try { + service.update(id, request); + } catch (RuntimeException e) { + auditService.logInNewTransaction(ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT_FAILED, + "DIRECTOR", id, request.getElectionId(), + "Director update failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + auditService.log(ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT, + "DIRECTOR", id, request.getElectionId(), + "Director appointment updated: " + request.getCandidateName(), + "designation=" + request.getDesignation() + ", status=" + request.getStatus(), + clientIp(httpRequest)); + return Response.ok(Map.of("resourceId", id)).build(); + } + + @DELETE + @Path("/{id}") + @Operation(summary = "Remove a director appointment") + public Response delete(@PathParam("id") Long id, + @Context HttpServletRequest httpRequest) { + try { + service.delete(id); + } catch (RuntimeException e) { + auditService.logInNewTransaction(ElectionAuditAction.REMOVE_DIRECTOR_APPOINTMENT_FAILED, + "DIRECTOR", id, null, + "Director removal failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + auditService.log(ElectionAuditAction.REMOVE_DIRECTOR_APPOINTMENT, + "DIRECTOR", id, null, + "Director appointment removed (id=" + id + ")", null, + clientIp(httpRequest)); + return Response.ok(Map.of("resourceId", id)).build(); + } + + private String clientIp(HttpServletRequest req) { + String ip = req.getHeader("X-Forwarded-For"); + return (ip != null && !ip.isBlank()) ? ip.split(",")[0].trim() : req.getRemoteAddr(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DirectorAppointmentData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DirectorAppointmentData.java new file mode 100644 index 000000000..baf21f35d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DirectorAppointmentData.java @@ -0,0 +1,31 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class DirectorAppointmentData { + + private Long id; + private Long electionId; + private Long candidateId; + private String candidateName; + private String postName; + private String designation; + private LocalDate tenureStartDate; + private LocalDate tenureEndDate; + private String resolutionNo; + private LocalDate appointmentDate; + private String status; + private String remarks; + private LocalDateTime createdDate; + private LocalDateTime lastModifiedDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DirectorAppointmentRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DirectorAppointmentRequest.java new file mode 100644 index 000000000..939a62f0d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DirectorAppointmentRequest.java @@ -0,0 +1,41 @@ +package org.apache.fineract.portfolio.election.data; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@NoArgsConstructor +public class DirectorAppointmentRequest { + + @NotNull(message = "Election ID is required") + private Long electionId; + + @NotNull(message = "Candidate ID is required") + private Long candidateId; + + @NotBlank(message = "Candidate name is required") + private String candidateName; + + @NotBlank(message = "Post name is required") + private String postName; + + @NotBlank(message = "Designation is required") + private String designation; + + @NotNull(message = "Appointment date is required") + private LocalDate appointmentDate; + + @NotNull(message = "Tenure start date is required") + private LocalDate tenureStartDate; + + @NotNull(message = "Tenure end date is required") + private LocalDate tenureEndDate; + + private String resolutionNo; + private String status; + private String remarks; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/PendingDirectorData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/PendingDirectorData.java new file mode 100644 index 000000000..917aea559 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/PendingDirectorData.java @@ -0,0 +1,19 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class PendingDirectorData { + private Long electionId; + private String electionTitle; + private Long candidateId; + private String candidateName; + private String postName; + private Integer voteCount; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointment.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointment.java new file mode 100644 index 000000000..380d80935 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointment.java @@ -0,0 +1,79 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_director_appointment") +@Getter +@Setter +@NoArgsConstructor +public class DirectorAppointment extends AbstractPersistableCustom { + + @Column(name = "election_id", nullable = false) + private Long electionId; + + @Column(name = "candidate_id", nullable = false) + private Long candidateId; + + @Column(name = "candidate_name", nullable = false, length = 200) + private String candidateName; + + @Column(name = "post_name", nullable = false, length = 200) + private String postName; + + @Column(name = "designation", length = 200) + private String designation; + + @Column(name = "tenure_start_date") + private LocalDate tenureStartDate; + + @Column(name = "tenure_end_date") + private LocalDate tenureEndDate; + + @Column(name = "resolution_no", length = 100) + private String resolutionNo; + + @Column(name = "appointment_date") + private LocalDate appointmentDate; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private DirectorAppointmentStatus status = DirectorAppointmentStatus.ACTIVE; + + @Column(name = "remarks", columnDefinition = "TEXT") + private String remarks; + + @Column(name = "created_date", nullable = false, updatable = false) + private LocalDateTime createdDate; + + @Column(name = "last_modified_date") + private LocalDateTime lastModifiedDate; + + @PrePersist + void onPersist() { + this.createdDate = LocalDateTime.now(); + } + + @PreUpdate + void onUpdate() { + this.lastModifiedDate = LocalDateTime.now(); + } + + public static DirectorAppointment create(Long electionId, Long candidateId, + String candidateName, String postName) { + DirectorAppointment d = new DirectorAppointment(); + d.electionId = electionId; + d.candidateId = candidateId; + d.candidateName = candidateName; + d.postName = postName; + d.status = DirectorAppointmentStatus.ACTIVE; + return d; + } +} 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 new file mode 100644 index 000000000..b64de7eab --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java @@ -0,0 +1,31 @@ +package org.apache.fineract.portfolio.election.domain; + +import org.springframework.data.domain.Page; +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.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface DirectorAppointmentRepository extends JpaRepository { + + Page findAll(Pageable pageable); + + Page findByStatus(DirectorAppointmentStatus status, Pageable pageable); + + Page findByElectionId(Long electionId, Pageable pageable); + + Page findByElectionIdAndStatus(Long electionId, DirectorAppointmentStatus status, Pageable pageable); + + List findByElectionIdAndCandidateId(Long electionId, Long candidateId); + + 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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentStatus.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentStatus.java new file mode 100644 index 000000000..679a6af01 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentStatus.java @@ -0,0 +1,8 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum DirectorAppointmentStatus { + ACTIVE, + RESIGNED, + REMOVED, + TENURE_ENDED +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java index 003af340a..870c1f8b5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java @@ -22,5 +22,8 @@ public enum ElectionAuditAction { // Voting CAST_VOTE, MANUAL_VOTE_ENTRY, MANUAL_VOTE_ENTRY_FAILED, // Results - DECLARE_RESULTS, DECLARE_RESULTS_FAILED + DECLARE_RESULTS, DECLARE_RESULTS_FAILED, + // Board of Directors + APPOINT_DIRECTOR, UPDATE_DIRECTOR_APPOINTMENT, REMOVE_DIRECTOR_APPOINTMENT, + APPOINT_DIRECTOR_FAILED, UPDATE_DIRECTOR_APPOINTMENT_FAILED, REMOVE_DIRECTOR_APPOINTMENT_FAILED } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResultRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResultRepository.java index 060eb1178..bda5776cd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResultRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionResultRepository.java @@ -13,4 +13,6 @@ public interface ElectionResultRepository extends JpaRepository findByElectionConfigIdAndElectionPostId(Long electionId, Long postId); boolean existsByElectionConfigId(Long electionId); + + List findByResult(String result); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentService.java new file mode 100644 index 000000000..ab868abb8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentService.java @@ -0,0 +1,25 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.data.DirectorAppointmentData; +import org.apache.fineract.portfolio.election.data.DirectorAppointmentRequest; +import org.apache.fineract.portfolio.election.data.PagedResponse; +import org.apache.fineract.portfolio.election.data.PendingDirectorData; + +import java.util.List; + +public interface DirectorAppointmentService { + + Long appoint(DirectorAppointmentRequest request); + + List retrievePending(); + + void autoExpireByTenure(); + + DirectorAppointmentData update(Long id, DirectorAppointmentRequest request); + + void delete(Long id); + + DirectorAppointmentData retrieve(Long id); + + PagedResponse retrieveAll(String status, Long electionId, int page, int size); +} 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 new file mode 100644 index 000000000..3255c9479 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java @@ -0,0 +1,181 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; +import org.apache.fineract.portfolio.election.data.DirectorAppointmentData; +import org.apache.fineract.portfolio.election.data.DirectorAppointmentRequest; +import org.apache.fineract.portfolio.election.data.PagedResponse; +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.ElectionResultRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class DirectorAppointmentServiceImpl implements DirectorAppointmentService { + + private static final Logger log = LoggerFactory.getLogger(DirectorAppointmentServiceImpl.class); + + private final DirectorAppointmentRepository repository; + private final ElectionResultRepository resultRepository; + + @Override + @Transactional + public Long appoint(DirectorAppointmentRequest req) { + validateTenureDates(req); + if (repository.existsByElectionIdAndCandidateId(req.getElectionId(), req.getCandidateId())) { + throw new IllegalStateException( + "Candidate " + req.getCandidateId() + " is already appointed for election " + req.getElectionId()); + } + DirectorAppointment d = DirectorAppointment.create( + req.getElectionId(), req.getCandidateId(), + req.getCandidateName(), req.getPostName()); + applyFields(d, req); + return repository.saveAndFlush(d).getId(); + } + + @Override + @Transactional(readOnly = true) + public List retrievePending() { + return resultRepository.findByResult("WON").stream() + .filter(r -> !repository.existsByElectionIdAndCandidateId( + r.getElectionConfig().getId(), r.getCandidate().getId())) + .map(r -> PendingDirectorData.builder() + .electionId(r.getElectionConfig().getId()) + .electionTitle(r.getElectionConfig().getTitle()) + .candidateId(r.getCandidate().getId()) + .candidateName(r.getCandidate().getCandidateName()) + .postName(r.getElectionPost().getPostName()) + .voteCount(r.getVoteCount()) + .build()) + .toList(); + } + + @Override + @Transactional + public DirectorAppointmentData update(Long id, DirectorAppointmentRequest req) { + validateTenureDates(req); + DirectorAppointment d = findOrThrow(id); + applyFields(d, req); + return toData(repository.save(d)); + } + + @Override + @Transactional + public void delete(Long id) { + repository.delete(findOrThrow(id)); + } + + @Override + @Transactional(readOnly = true) + public DirectorAppointmentData retrieve(Long id) { + return toData(findOrThrow(id)); + } + + @Override + @Transactional + @Scheduled(cron = "0 0 0 * * *") + public void autoExpireByTenure() { + int updated = repository.expireByTenureDate(LocalDate.now()); + if (updated > 0) { + log.info("Auto-expired {} director appointment(s) whose tenure ended.", updated); + } + } + + @Override + @Transactional(readOnly = true) + public PagedResponse retrieveAll(String status, Long electionId, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdDate").descending()); + Page result; + + DirectorAppointmentStatus statusEnum = null; + if (status != null && !status.isBlank()) { + try { statusEnum = DirectorAppointmentStatus.valueOf(status.toUpperCase()); } catch (Exception ignored) {} + } + + if (electionId != null && statusEnum != null) { + result = repository.findByElectionIdAndStatus(electionId, statusEnum, pageable); + } else if (electionId != null) { + result = repository.findByElectionId(electionId, pageable); + } else if (statusEnum != null) { + result = repository.findByStatus(statusEnum, pageable); + } else { + result = repository.findAll(pageable); + } + + return PagedResponse.builder() + .content(result.getContent().stream().map(this::toData).toList()) + .pageNumber(result.getNumber()) + .pageSize(result.getSize()) + .totalElements(result.getTotalElements()) + .totalPages(result.getTotalPages()) + .first(result.isFirst()) + .last(result.isLast()) + .build(); + } + + // ------------------------------------------------------------------------- + + private void applyFields(DirectorAppointment d, DirectorAppointmentRequest req) { + if (req.getDesignation() != null) d.setDesignation(req.getDesignation()); + if (req.getTenureStartDate() != null) d.setTenureStartDate(req.getTenureStartDate()); + if (req.getTenureEndDate() != null) d.setTenureEndDate(req.getTenureEndDate()); + if (req.getResolutionNo() != null) d.setResolutionNo(req.getResolutionNo()); + if (req.getAppointmentDate() != null) d.setAppointmentDate(req.getAppointmentDate()); + if (req.getRemarks() != null) d.setRemarks(req.getRemarks()); + if (req.getStatus() != null) { + try { d.setStatus(DirectorAppointmentStatus.valueOf(req.getStatus().toUpperCase())); } catch (Exception ignored) {} + } + if (req.getCandidateName() != null) d.setCandidateName(req.getCandidateName()); + if (req.getPostName() != null) d.setPostName(req.getPostName()); + } + + private void validateTenureDates(DirectorAppointmentRequest req) { + if (req.getTenureStartDate() != null && req.getTenureEndDate() != null + && !req.getTenureEndDate().isAfter(req.getTenureStartDate())) { + throw new IllegalArgumentException("Tenure end date must be after tenure start date"); + } + if (req.getAppointmentDate() != null && req.getTenureStartDate() != null + && req.getTenureStartDate().isBefore(req.getAppointmentDate())) { + throw new IllegalArgumentException("Tenure start date cannot be before appointment date"); + } + } + + private DirectorAppointment findOrThrow(Long id) { + return repository.findById(id).orElseThrow( + () -> new AbstractPlatformResourceNotFoundException("error.msg.director.appointment.not.found", + "Director appointment not found with id: " + id, id) {}); + } + + private DirectorAppointmentData toData(DirectorAppointment d) { + return DirectorAppointmentData.builder() + .id(d.getId()) + .electionId(d.getElectionId()) + .candidateId(d.getCandidateId()) + .candidateName(d.getCandidateName()) + .postName(d.getPostName()) + .designation(d.getDesignation()) + .tenureStartDate(d.getTenureStartDate()) + .tenureEndDate(d.getTenureEndDate()) + .resolutionNo(d.getResolutionNo()) + .appointmentDate(d.getAppointmentDate()) + .status(d.getStatus() != null ? d.getStatus().name() : null) + .remarks(d.getRemarks()) + .createdDate(d.getCreatedDate()) + .lastModifiedDate(d.getLastModifiedDate()) + .build(); + } +} 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 b3b4761ec..4825d7549 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 @@ -374,4 +374,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_election_director_appointment.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_election_director_appointment.xml new file mode 100644 index 000000000..893e8e868 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0347_election_director_appointment.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +