diff --git a/src/main/java/net/gepafin/tendermanagement/dao/ApplicationDao.java b/src/main/java/net/gepafin/tendermanagement/dao/ApplicationDao.java index 631f8733..4ce2ceb6 100644 --- a/src/main/java/net/gepafin/tendermanagement/dao/ApplicationDao.java +++ b/src/main/java/net/gepafin/tendermanagement/dao/ApplicationDao.java @@ -1173,26 +1173,57 @@ public class ApplicationDao { validateRankingActionRequest(rankingActionType, manualRanking); ApplicationEntity oldApplicationEntity = Utils.getClonedEntityForData(applicationEntity); if (rankingActionType == null) { + compactManualRanksAfterLeavingReposition(applicationEntity); applicationEntity.setRankingActionType(null); applicationEntity.setManualRanking(null); } else { - if (rankingActionType == ApplicationRankingActionTypeEnum.REPOSITION - && applicationRepository.existsByCallIdAndManualRankingAndIsDeletedFalseAndIdNot( - applicationEntity.getCall().getId(), manualRanking, applicationEntity.getId())) { - throw new CustomValidationException(Status.BAD_REQUEST, - Translator.toLocale(GepafinConstant.APPLICATION_RANKING_ACTION_INVALID)); + if (rankingActionType == ApplicationRankingActionTypeEnum.REPOSITION) { + shiftOtherManualRanksForReposition(applicationEntity, manualRanking); + } else if (rankingActionType == ApplicationRankingActionTypeEnum.REMOVE) { + compactManualRanksAfterLeavingReposition(applicationEntity); } applicationEntity.setRankingActionType(rankingActionType.getValue()); applicationEntity.setManualRanking( rankingActionType == ApplicationRankingActionTypeEnum.REPOSITION ? manualRanking : null); } applicationEntity = applicationRepository.save(applicationEntity); + normalizeDenseManualRanksForCall(applicationEntity.getCall().getId()); + applicationEntity = applicationRepository.findById(applicationEntity.getId()).orElse(applicationEntity); loggingUtil.addVersionHistory( VersionHistoryRequest.builder().request(request).actionType(VersionActionTypeEnum.UPDATE) .oldData(oldApplicationEntity).newData(applicationEntity).build()); return getApplicationResponse(applicationEntity); } + /** + * Keeps stored manual ranks in {@code 1..N} for the call (same count as repositioned rows with a manual value), + * preserving relative order. Prevents gaps or values above the pool size (e.g. 2,3,4,5 with four apps → 1,2,3,4). + */ + private void normalizeDenseManualRanksForCall(Long callId) { + String reposition = ApplicationRankingActionTypeEnum.REPOSITION.getValue(); + List repositioned = applicationRepository.findByCallIdAndIsDeletedFalse(callId).stream() + .filter(a -> ApplicationStatusTypeEnum.APPROVED.getValue().equals(a.getStatus())) + .filter(a -> reposition.equalsIgnoreCase(StringUtils.trimToEmpty(a.getRankingActionType()))) + .filter(a -> a.getManualRanking() != null) + .sorted(Comparator.comparing(ApplicationEntity::getManualRanking).thenComparing(ApplicationEntity::getId)) + .collect(Collectors.toList()); + if (repositioned.isEmpty()) { + return; + } + boolean changed = false; + long slot = 1L; + for (ApplicationEntity a : repositioned) { + if (!Objects.equals(a.getManualRanking(), slot)) { + a.setManualRanking(slot); + changed = true; + } + slot++; + } + if (changed) { + applicationRepository.saveAll(repositioned); + } + } + public CallRankingSummaryResponse getApplicationRanking(Long callId, List rankingActionTypes) { CallEntity call = callRepository.findById(callId) @@ -1269,6 +1300,48 @@ public class ApplicationDao { } } + /** + * When an application stops using manual REPOSITION (cleared or REMOVE), every other manual rank above its + * old slot shifts down by one so ranks stay dense (e.g. after removing rank 1, 2 and 3 become 1 and 2). + */ + private void compactManualRanksAfterLeavingReposition(ApplicationEntity applicationEntity) { + String repositionType = ApplicationRankingActionTypeEnum.REPOSITION.getValue(); + if (!repositionType.equalsIgnoreCase(StringUtils.trimToEmpty(applicationEntity.getRankingActionType()))) { + return; + } + Long releasedRank = applicationEntity.getManualRanking(); + if (releasedRank == null) { + return; + } + applicationRepository.compactRepositionedManualRankingAfterSlotFreed( + applicationEntity.getCall().getId(), + applicationEntity.getId(), + releasedRank, + repositionType); + } + + private void shiftOtherManualRanksForReposition(ApplicationEntity applicationEntity, Long newRank) { + Long callId = applicationEntity.getCall().getId(); + Long applicationId = applicationEntity.getId(); + String repositionType = ApplicationRankingActionTypeEnum.REPOSITION.getValue(); + Long oldRank = applicationEntity.getManualRanking(); + if (Objects.equals(oldRank, newRank)) { + return; + } + if (oldRank == null) { + applicationRepository.bumpRepositionedManualRankingFromRankInclusive(callId, applicationId, newRank, + repositionType); + return; + } + if (newRank < oldRank) { + applicationRepository.bumpRepositionedManualRankingUpInHalfOpenInterval(callId, applicationId, newRank, + oldRank, repositionType); + } else { + applicationRepository.bumpRepositionedManualRankingDownInHalfOpenInterval(callId, applicationId, oldRank, + newRank, repositionType); + } + } + /** * Sets all non-terminal amendments, the application evaluation (if any), and the assigned application row to CLOSE. */ diff --git a/src/main/java/net/gepafin/tendermanagement/repositories/ApplicationRepository.java b/src/main/java/net/gepafin/tendermanagement/repositories/ApplicationRepository.java index 72c2b678..d85a211a 100644 --- a/src/main/java/net/gepafin/tendermanagement/repositories/ApplicationRepository.java +++ b/src/main/java/net/gepafin/tendermanagement/repositories/ApplicationRepository.java @@ -185,4 +185,67 @@ public interface ApplicationRepository extends JpaRepository :applicationId " + + "AND a.manualRanking >= :fromRank") + int bumpRepositionedManualRankingFromRankInclusive(@Param("callId") Long callId, + @Param("applicationId") Long applicationId, + @Param("fromRank") Long fromRank, + @Param("repositionType") String repositionType); + + /** + * Increments manual_ranking by 1 for ranks in [lowRank, highRank) — used when moving this application to a better (smaller) rank. + */ + @Modifying + @Transactional + @Query("UPDATE ApplicationEntity a SET a.manualRanking = a.manualRanking + 1 " + + "WHERE a.call.id = :callId AND a.isDeleted = false " + + "AND a.rankingActionType = :repositionType " + + "AND a.manualRanking IS NOT NULL AND a.id <> :applicationId " + + "AND a.manualRanking >= :lowRank AND a.manualRanking < :highRank") + int bumpRepositionedManualRankingUpInHalfOpenInterval(@Param("callId") Long callId, + @Param("applicationId") Long applicationId, + @Param("lowRank") Long lowRank, + @Param("highRank") Long highRank, + @Param("repositionType") String repositionType); + + /** + * Decrements manual_ranking by 1 for ranks in (lowRank, highRank] — used when moving this application to a worse (larger) rank. + */ + @Modifying + @Transactional + @Query("UPDATE ApplicationEntity a SET a.manualRanking = a.manualRanking - 1 " + + "WHERE a.call.id = :callId AND a.isDeleted = false " + + "AND a.rankingActionType = :repositionType " + + "AND a.manualRanking IS NOT NULL AND a.id <> :applicationId " + + "AND a.manualRanking > :lowRank AND a.manualRanking <= :highRank") + int bumpRepositionedManualRankingDownInHalfOpenInterval(@Param("callId") Long callId, + @Param("applicationId") Long applicationId, + @Param("lowRank") Long lowRank, + @Param("highRank") Long highRank, + @Param("repositionType") String repositionType); + + /** + * After this application leaves manual REPOSITION, closes the gap: every other REPOSITION row with rank + * strictly greater than {@code releasedRank} moves down by 1. + */ + @Modifying + @Transactional + @Query("UPDATE ApplicationEntity a SET a.manualRanking = a.manualRanking - 1 " + + "WHERE a.call.id = :callId AND a.isDeleted = false " + + "AND a.rankingActionType = :repositionType " + + "AND a.manualRanking IS NOT NULL AND a.id <> :applicationId " + + "AND a.manualRanking > :releasedRank") + int compactRepositionedManualRankingAfterSlotFreed(@Param("callId") Long callId, + @Param("applicationId") Long applicationId, + @Param("releasedRank") Long releasedRank, + @Param("repositionType") String repositionType); + } diff --git a/src/main/resources/db/changelog/db.changelog-1.0.0.xml b/src/main/resources/db/changelog/db.changelog-1.0.0.xml index 1722b34b..99f4c9cb 100644 --- a/src/main/resources/db/changelog/db.changelog-1.0.0.xml +++ b/src/main/resources/db/changelog/db.changelog-1.0.0.xml @@ -3282,4 +3282,7 @@ + + + diff --git a/src/main/resources/db/dump/update_application_ranking_view_15_04_2026.sql b/src/main/resources/db/dump/update_application_ranking_view_15_04_2026.sql new file mode 100644 index 00000000..2283148f --- /dev/null +++ b/src/main/resources/db/dump/update_application_ranking_view_15_04_2026.sql @@ -0,0 +1,237 @@ +DROP VIEW IF EXISTS application_ranking_view; + +CREATE OR REPLACE VIEW application_ranking_view AS +WITH evaluation_scores AS ( + SELECT + ae.application_id, + COALESCE( + SUM( + COALESCE(NULLIF(TRIM(score_item ->> 'score'), ''), '0')::numeric + ), + 0 + ) AS total_score + FROM application_evaluation ae + LEFT JOIN LATERAL jsonb_array_elements( + CASE + WHEN ae.criteria IS NULL OR BTRIM(ae.criteria) = '' THEN '[]'::jsonb + ELSE ae.criteria::jsonb + END + ) score_item ON TRUE + WHERE ae.is_deleted = false + GROUP BY ae.application_id +), +approved_applications AS ( + SELECT + a.id AS application_id, + a.call_id, + a.company_id, + a.user_id, + a.status AS status, + a.submission_date, + a.amount_requested, + a.amount_accepted, + a.manual_ranking, + a.ranking_action_type, + a.ndg, + a.pec_email, + c.ranking_type, + p.created_date AS protocol_datetime, + p.protocol_number AS protocol_number, + COALESCE(a.amount_accepted, a.amount_requested, 0) AS amount, + COALESCE(es.total_score, 0) AS total_score + FROM application a + JOIN call c ON c.id = a.call_id + AND (c.is_deleted = false OR c.is_deleted IS NULL) + AND NULLIF(BTRIM(c.ranking_type), '') IS NOT NULL + LEFT JOIN protocol p ON p.id = a.protocol_number + LEFT JOIN evaluation_scores es ON es.application_id = a.id + WHERE (a.is_deleted = false OR a.is_deleted IS NULL) + AND a.status = 'APPROVED' + AND COALESCE(a.ranking_action_type, '') <> 'EXCLUDE' +), +ranking_core AS ( + SELECT * + FROM approved_applications + WHERE COALESCE(ranking_action_type, '') <> 'REMOVE' +), +remove_apps AS ( + SELECT * + FROM approved_applications + WHERE COALESCE(ranking_action_type, '') = 'REMOVE' +), +natural_ranked AS ( + SELECT + rc.*, + ROW_NUMBER() OVER ( + PARTITION BY rc.call_id + ORDER BY + CASE WHEN rc.ranking_type = 'SCORE' THEN rc.total_score END DESC NULLS LAST, + CASE WHEN rc.ranking_type = 'PROTOCOL_DATE_TIME' THEN rc.protocol_datetime END ASC NULLS LAST, + rc.protocol_datetime ASC NULLS LAST, + rc.application_id ASC + ) AS natural_rank + FROM ranking_core rc +), +call_limits AS ( + SELECT + nr.call_id, + COUNT(*)::bigint AS app_cnt, + COALESCE( + MAX( + CASE + WHEN COALESCE(nr.ranking_action_type, '') = 'REPOSITION' + AND nr.manual_ranking IS NOT NULL + THEN nr.manual_ranking + END + ), + 0 + ) AS max_manual + FROM natural_ranked nr + GROUP BY nr.call_id +), +free_ranks AS ( + SELECT + cl.call_id, + gs.slot::bigint AS listing_rank, + ROW_NUMBER() OVER (PARTITION BY cl.call_id ORDER BY gs.slot) AS fill_order + FROM call_limits cl + CROSS JOIN LATERAL generate_series( + 1, + GREATEST(cl.app_cnt::int, cl.max_manual::int) + ) AS gs(slot) + WHERE NOT EXISTS ( + SELECT 1 + FROM natural_ranked mr + WHERE mr.call_id = cl.call_id + AND COALESCE(mr.ranking_action_type, '') = 'REPOSITION' + AND mr.manual_ranking IS NOT NULL + AND mr.manual_ranking = gs.slot + ) +), +non_manual_ordered AS ( + SELECT + nr.*, + ROW_NUMBER() OVER ( + PARTITION BY nr.call_id + ORDER BY + CASE WHEN nr.ranking_type = 'SCORE' THEN nr.total_score END DESC NULLS LAST, + CASE WHEN nr.ranking_type = 'PROTOCOL_DATE_TIME' THEN nr.protocol_datetime END ASC NULLS LAST, + nr.protocol_datetime ASC NULLS LAST, + nr.application_id ASC + ) AS fill_order + FROM natural_ranked nr + WHERE COALESCE(nr.ranking_action_type, '') <> 'REPOSITION' + OR nr.manual_ranking IS NULL +), +manual_part AS ( + SELECT + mr.call_id, + mr.application_id, + mr.ranking_action_type, + mr.total_score, + mr.user_id, + mr.status, + mr.submission_date, + mr.protocol_datetime, + mr.protocol_number, + mr.ndg, + mr.amount_accepted, + mr.pec_email, + mr.manual_ranking, + mr.ranking_type, + mr.manual_ranking AS listing_rank + FROM natural_ranked mr + WHERE COALESCE(mr.ranking_action_type, '') = 'REPOSITION' + AND mr.manual_ranking IS NOT NULL +), +non_manual_part AS ( + SELECT + nmo.call_id, + nmo.application_id, + nmo.ranking_action_type, + nmo.total_score, + nmo.user_id, + nmo.status, + nmo.submission_date, + nmo.protocol_datetime, + nmo.protocol_number, + nmo.ndg, + nmo.amount_accepted, + nmo.pec_email, + nmo.manual_ranking, + nmo.ranking_type, + fr.listing_rank + FROM non_manual_ordered nmo + JOIN free_ranks fr + ON fr.call_id = nmo.call_id + AND fr.fill_order = nmo.fill_order +), +ranked_pool AS ( + SELECT * FROM manual_part + UNION ALL + SELECT * FROM non_manual_part +), +pool_max AS ( + SELECT call_id, COALESCE(MAX(listing_rank), 0) AS max_lr + FROM ranked_pool + GROUP BY call_id +), +remove_part AS ( + SELECT + ra.call_id, + ra.application_id, + ra.ranking_action_type, + ra.total_score, + ra.user_id, + ra.status, + ra.submission_date, + ra.protocol_datetime, + ra.protocol_number, + ra.ndg, + ra.amount_accepted, + ra.pec_email, + ra.manual_ranking, + ra.ranking_type, + COALESCE(pm.max_lr, 0) + + ROW_NUMBER() OVER ( + PARTITION BY ra.call_id + ORDER BY ra.protocol_datetime ASC NULLS LAST, ra.application_id + ) AS listing_rank + FROM remove_apps ra + LEFT JOIN pool_max pm ON pm.call_id = ra.call_id +) +SELECT + rp.listing_rank, + rp.application_id, + rp.call_id, + rp.ranking_action_type, + rp.total_score, + rp.user_id, + rp.status, + rp.submission_date, + rp.protocol_datetime, + rp.protocol_number, + rp.ndg, + rp.amount_accepted, + rp.pec_email, + rp.manual_ranking, + rp.ranking_type +FROM ranked_pool rp +UNION ALL +SELECT + rv.listing_rank, + rv.application_id, + rv.call_id, + rv.ranking_action_type, + rv.total_score, + rv.user_id, + rv.status, + rv.submission_date, + rv.protocol_datetime, + rv.protocol_number, + rv.ndg, + rv.amount_accepted, + rv.pec_email, + rv.manual_ranking, + rv.ranking_type +FROM remove_part rv order by listing_rank asc;