From 8af00b35fb5f9051e3a28ac58b81ab0dcf1997d1 Mon Sep 17 00:00:00 2001 From: link Date: Fri, 28 Aug 2026 17:15:51 +0200 Subject: [PATCH 1/4] Aggiunta gestione filtri pendenze prima parte issue #66 --- .../console/pendenza/PendenzaListQuery.java | 12 ++- .../console/pendenza/PendenzaMapper.java | 3 +- .../console/pendenza/PendenzaService.java | 26 ++++- .../console/pendenza/PendenzaSortParser.java | 8 +- .../pendenza/PendenzaSpecifications.java | 94 +++++++++++++++++++ src/main/resources/openapi/openapi.yaml | 64 +++++++++++-- .../console/pendenza/PendenzaMapperTest.java | 93 ++++++++++++++++++ 7 files changed, 284 insertions(+), 16 deletions(-) create mode 100644 src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java b/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java index c47d87d..c8b5140 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java @@ -1,5 +1,9 @@ package it.govpay.console.pendenza; +import java.time.LocalDate; + +import it.govpay.console.model.StatoPendenza; + public record PendenzaListQuery( int page, int limit, @@ -9,5 +13,11 @@ public record PendenzaListQuery( String idPendenza, String numeroAvviso, String idDominio, - String identificativoDebitore) { + String identificativoDebitore, + StatoPendenza stato, + LocalDate dataDa, + LocalDate dataA, + String iuv, + String direzione, + String divisione) { } diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java b/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java index fc5973a..add6a97 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java @@ -93,7 +93,8 @@ private static StatoPendenza baseMapStato(String statoV1) { // V1 usa varianti femminili/maschili; normalizziamo prima di mappare. String normalized = statoV1.trim().toUpperCase(); return switch (normalized) { - case "ESEGUITA", "ESEGUITO", "PAGATA", "PAGATO" -> StatoPendenza.PAGATA; + case "ESEGUITA", "ESEGUITO", "PAGATA", "PAGATO", "ESEGUITO_ALTRO_CANALE", "ESEGUITO_SENZA_RPT" -> + StatoPendenza.PAGATA; case "NON_ESEGUITA", "NON_ESEGUITO", "NON_PAGATA", "NON_PAGATO" -> StatoPendenza.NON_PAGATA; case "ESEGUITA_PARZIALE", "ESEGUITO_PARZIALE", "PAGATA_PARZIALE", "PAGATO_PARZIALE" -> StatoPendenza.PAGATA_PARZIALE; diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaService.java b/src/main/java/it/govpay/console/pendenza/PendenzaService.java index 0cc7e52..28013b2 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaService.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaService.java @@ -1,5 +1,6 @@ package it.govpay.console.pendenza; +import java.time.Clock; import java.time.OffsetDateTime; import java.util.ArrayList; import java.util.HashMap; @@ -55,6 +56,7 @@ public class PendenzaService { private final PendenzaLinksBuilder linksBuilder; private final CurrentOperatorService currentOperatorService; private final AuditService auditService; + private final Clock clock; @PersistenceContext private EntityManager entityManager; @@ -63,12 +65,14 @@ public PendenzaService(VersamentoRepository repository, PendenzaMapper mapper, PendenzaLinksBuilder linksBuilder, CurrentOperatorService currentOperatorService, - AuditService auditService) { + AuditService auditService, + Clock clock) { this.repository = repository; this.mapper = mapper; this.linksBuilder = linksBuilder; this.currentOperatorService = currentOperatorService; this.auditService = auditService; + this.clock = clock; } @Transactional(readOnly = true) @@ -99,12 +103,18 @@ public Pendenza get(String idA2A, String idPendenza, Set expand) @Transactional(readOnly = true) public ListPendenze200Response list(PendenzaListQuery query, HttpServletRequest request) { OperatoreCorrente operatore = currentOperatorService.get(); - log.debug("listPendenze filtri[idPendenza={}, numeroAvviso={}, idDominio={}, identificativoDebitore={}], " + log.debug("listPendenze filtri[idPendenza={}, numeroAvviso={}, idDominio={}, identificativoDebitore={}, " + + "stato={}, dataDa={}, dataA={}, iuv={}, direzione={}, divisione={}], " + "page={}, limit={}, sort={}, total={}, cursor={}, operatore={}", query.idPendenza(), query.numeroAvviso(), query.idDominio(), query.identificativoDebitore(), + query.stato(), query.dataDa(), query.dataA(), query.iuv(), query.direzione(), query.divisione(), query.page(), query.limit(), query.sort(), query.total(), query.cursor() != null, operatore.principal()); + if (query.dataDa() != null && query.dataA() != null && query.dataDa().isAfter(query.dataA())) { + throw new BadRequestException("'dataDa' non puo' essere successiva a 'dataA'."); + } + // Spring Data JPA 4.x: Specification.allOf rifiuta null. Filtriamo i predicati assenti. Specification spec = Specification.allOf( java.util.stream.Stream.of( @@ -112,6 +122,12 @@ public ListPendenze200Response list(PendenzaListQuery query, HttpServletRequest PendenzaSpecifications.numeroAvvisoExact(query.numeroAvviso()), PendenzaSpecifications.idDominioExact(query.idDominio()), PendenzaSpecifications.identificativoDebitoreExact(query.identificativoDebitore()), + PendenzaSpecifications.statoExact(query.stato(), OffsetDateTime.now(clock)), + PendenzaSpecifications.dataCreazioneDa(query.dataDa()), + PendenzaSpecifications.dataCreazioneA(query.dataA()), + PendenzaSpecifications.iuvExact(query.iuv()), + PendenzaSpecifications.direzioneExact(query.direzione()), + PendenzaSpecifications.divisioneExact(query.divisione()), PendenzaSpecifications.visibiliPerOperatore(operatore)) .filter(java.util.Objects::nonNull) .toList()); @@ -180,7 +196,7 @@ private List listOffsetMode(Specification spec, /** * Modalita' cursor (keyset): ordina per - * {@code (dataOraUltimoAggiornamento DESC, id DESC)} e filtra con + * {@code (dataCreazione DESC, id DESC)} e filtra con * {@code WHERE data < :ts OR (data = :ts AND id < :id)}. Carica {@code limit+1} * righe per determinare {@code hasNext}. * @@ -200,7 +216,7 @@ private List listCursorMode(Specification spec, if (hasNext && !rows.isEmpty()) { Versamento last = rows.get(rows.size() - 1); - response.setNextCursor(CursorCodec.encode(last.getDataOraUltimoAggiornamento(), last.getId())); + response.setNextCursor(CursorCodec.encode(last.getDataCreazione(), last.getId())); } return rows; } @@ -213,7 +229,7 @@ private List findByCursor(Specification spec, Root root = q.from(Versamento.class); Predicate specPredicate = spec.toPredicate(root, q, cb); - Path dataPath = root.get("dataOraUltimoAggiornamento"); + Path dataPath = root.get("dataCreazione"); Path idPath = root.get("id"); Predicate where; diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaSortParser.java b/src/main/java/it/govpay/console/pendenza/PendenzaSortParser.java index b83d4f4..da18b62 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaSortParser.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaSortParser.java @@ -15,20 +15,22 @@ public final class PendenzaSortParser { /** - * Campi sortable allineati a V1 ({@code ListaPendenzeDTO}: {@code dataCaricamento}, + * Campi sortable allineati a V1 ({@code ListaPendenzeDTO}: {@code dataCreazione}, * {@code dataValidita}, {@code dataScadenza}, {@code stato}) piu' * {@code dataUltimoAggiornamento} aggiunto come default dall'issue #9. + * {@code dataCreazione} (rinominata da {@code dataCaricamento}, issue #66) + * e' anche il default e la chiave dell'ordinamento cursor. * Le chiavi della map sono i nomi pubblici (query param), i valori i nomi * dei campi entity JPA per la query. */ private static final Map WHITELIST = Map.of( "dataUltimoAggiornamento", "dataOraUltimoAggiornamento", - "dataCaricamento", "dataCreazione", + "dataCreazione", "dataCreazione", "dataValidita", "dataValidita", "dataScadenza", "dataScadenza", "stato", "statoVersamento"); - public static final String DEFAULT_SORT_RAW = "-dataUltimoAggiornamento"; + public static final String DEFAULT_SORT_RAW = "-dataCreazione"; private PendenzaSortParser() { } diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java b/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java index f4bec52..07892b9 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java @@ -1,8 +1,14 @@ package it.govpay.console.pendenza; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + import org.springframework.data.jpa.domain.Specification; import it.govpay.console.entity.Versamento; +import it.govpay.console.model.StatoPendenza; import it.govpay.console.security.OperatoreCorrente; import it.govpay.console.security.VersamentoVisibilita; @@ -40,6 +46,94 @@ public static Specification identificativoDebitoreExact(String value return (root, q, cb) -> cb.equal(root.get("srcDebitoreIdentificativo"), value); } + /** Limite inferiore incluso sulla data di creazione ({@code data_creazione}). */ + public static Specification dataCreazioneDa(LocalDate da) { + if (da == null) { + return null; + } + OffsetDateTime from = da.atStartOfDay().atOffset(ZoneOffset.UTC); + return (root, q, cb) -> cb.greaterThanOrEqualTo(root.get("dataCreazione"), from); + } + + /** Limite superiore incluso: {@code data_creazione < (dataA + 1 giorno)}. */ + public static Specification dataCreazioneA(LocalDate a) { + if (a == null) { + return null; + } + OffsetDateTime toExclusive = a.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC); + return (root, q, cb) -> cb.lessThan(root.get("dataCreazione"), toExclusive); + } + + public static Specification iuvExact(String value) { + if (value == null || value.isBlank()) { + return null; + } + return (root, q, cb) -> cb.equal(root.get("iuvVersamento"), value); + } + + public static Specification direzioneExact(String value) { + if (value == null || value.isBlank()) { + return null; + } + return (root, q, cb) -> cb.equal(root.get("direzione"), value); + } + + public static Specification divisioneExact(String value) { + if (value == null || value.isBlank()) { + return null; + } + return (root, q, cb) -> cb.equal(root.get("divisione"), value); + } + + private static final List RAW_PAGATA = + List.of("ESEGUITA", "ESEGUITO", "PAGATA", "PAGATO", "ESEGUITO_ALTRO_CANALE", "ESEGUITO_SENZA_RPT"); + private static final List RAW_NON_ESEGUITO = + List.of("NON_ESEGUITA", "NON_ESEGUITO", "NON_PAGATA", "NON_PAGATO"); + private static final List RAW_PAGATA_PARZIALE = + List.of("ESEGUITA_PARZIALE", "ESEGUITO_PARZIALE", "PAGATA_PARZIALE", "PAGATO_PARZIALE", "PARZIALMENTE_ESEGUITO"); + private static final List RAW_RICONCILIATA = + List.of("INCASSATA", "INCASSATO", "RICONCILIATA", "RICONCILIATO"); + private static final List RAW_ANNULLATA = List.of("ANNULLATA", "ANNULLATO"); + private static final List RAW_ANOMALA = List.of("ANOMALA", "ANOMALO"); + + /** + * Traduce lo stato V2 sul/i valore/i grezzo/i di {@code stato_versamento}. + * V1 non e' consistente sul genere del valore grezzo (visto sia + * {@code ESEGUITO} che {@code ESEGUITA} in dati reali): ogni stato include + * tutte le varianti riconosciute, esattamente come gia' fa + * {@link PendenzaMapper#mapStato} in lettura — un filtro piu' stretto + * lascerebbe fuori righe che l'output mostra correttamente mappate. + * {@code PAGATA} include anche gli stati interni equivalenti + * ({@code ESEGUITO_ALTRO_CANALE}, {@code ESEGUITO_SENZA_RPT}). + * {@code NON_PAGATA} e {@code SCADUTA} condividono lo stesso valore grezzo + * e si distinguono solo per {@code data_scadenza} rispetto a {@code now} — + * stessa semantica di V1 (V1 {@code PendenzeDAO}: + * {@code AbilitaFiltroNonScaduto}/{@code AbilitaFiltroScaduto}), non un + * {@code equal} semplice. Righe con un valore grezzo non riconosciuto (che + * il mapper di output marca comunque {@code ANOMALA} per default) non sono + * raggiunte da nessuno stato filtrabile: limite noto, non un requisito di + * questa issue. + */ + public static Specification statoExact(StatoPendenza stato, OffsetDateTime now) { + if (stato == null) { + return null; + } + return switch (stato) { + case PAGATA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_PAGATA); + case PAGATA_PARZIALE -> (root, q, cb) -> root.get("statoVersamento").in(RAW_PAGATA_PARZIALE); + case RICONCILIATA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_RICONCILIATA); + case ANNULLATA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_ANNULLATA); + case ANOMALA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_ANOMALA); + case NON_PAGATA -> (root, q, cb) -> cb.and( + root.get("statoVersamento").in(RAW_NON_ESEGUITO), + cb.or(cb.isNull(root.get("dataScadenza")), cb.greaterThanOrEqualTo(root.get("dataScadenza"), now))); + case SCADUTA -> (root, q, cb) -> cb.and( + root.get("statoVersamento").in(RAW_NON_ESEGUITO), + cb.isNotNull(root.get("dataScadenza")), + cb.lessThan(root.get("dataScadenza"), now)); + }; + } + /** * Limita i risultati alle pendenze visibili all'operatore corrente. Delega la * regola ACL alla single-source {@link VersamentoVisibilita}. diff --git a/src/main/resources/openapi/openapi.yaml b/src/main/resources/openapi/openapi.yaml index 9db58c4..295b7b7 100644 --- a/src/main/resources/openapi/openapi.yaml +++ b/src/main/resources/openapi/openapi.yaml @@ -1159,16 +1159,20 @@ paths: operationId: listPendenze summary: Elenca le pendenze visibili all'operatore autenticato (paginate). description: | - Filtri supportati in Fase 1 (Issue #9): + Filtri supportati: - `idPendenza` — partial match; - `numeroAvviso` — match esatto, 18 cifre; - `idDominio` — match esatto, 11 caratteri; - - `identificativoDebitore` — match esatto. + - `identificativoDebitore` — match esatto; + - `stato` — match esatto sull'enum `StatoPendenza`; + - `dataDa`/`dataA` — intervallo incluso sulla data di creazione della pendenza; + - `iuv` — match esatto; + - `direzione`, `divisione` — match esatto. Tutti i filtri sono opzionali e ortogonali (AND). I filtri V1 non - ancora supportati (`idA2A`, `iuv`, `stato`, `tipoPendenza`, `dataDa`, - `dataA`, `direzione`, `divisione`, `idDebitore`, `mostraSpontaneiNonPagati`) - ritornano 400 problem+json. + ancora supportati (`idA2A`, `idTipoPendenza`) ritornano 400 problem+json. + + `dataDa` successivo a `dataA` → 400 problem+json. ACL: il filtro per dominio dell'operatore corrente viene applicato silenziosamente; un operatore senza visibilita' su alcun dominio riceve @@ -1180,7 +1184,7 @@ paths: **Paginazione**: due modalita' mutuamente esclusive. - **offset** (default): `?page=N&limit=L`; risposta con `pagination` valorizzato. - **cursor** (opt-in): `?cursor=`; risposta con `nextCursor`, - ordinamento fisso `(dataOraUltimoAggiornamento DESC, id DESC)`. + ordinamento fisso `(dataCreazione DESC, id DESC)`. In modalita' cursor `page` esplicito, `sort` esplicito o `total=true` generano 400 problem+json con motivazione parlante. Cursor malformato → 400. @@ -1194,6 +1198,12 @@ paths: - $ref: '#/components/parameters/NumeroAvviso' - $ref: '#/components/parameters/IdDominio' - $ref: '#/components/parameters/IdentificativoDebitore' + - $ref: '#/components/parameters/StatoPendenzaFilter' + - $ref: '#/components/parameters/PendenzaDataDa' + - $ref: '#/components/parameters/PendenzaDataA' + - $ref: '#/components/parameters/IuvFilter' + - $ref: '#/components/parameters/DirezioneFilter' + - $ref: '#/components/parameters/DivisioneFilter' responses: '200': description: Lista paginata di pendenze. @@ -12418,6 +12428,48 @@ components: schema: type: string + StatoPendenzaFilter: + name: stato + in: query + required: false + description: Filtra per stato della pendenza. + schema: + $ref: '#/components/schemas/StatoPendenza' + + PendenzaDataDa: + name: dataDa + in: query + required: false + description: Limite inferiore (incluso) sulla data di creazione della pendenza (ISO 8601 date). + schema: + type: string + format: date + + PendenzaDataA: + name: dataA + in: query + required: false + description: Limite superiore (incluso) sulla data di creazione della pendenza (ISO 8601 date). + schema: + type: string + format: date + + DirezioneFilter: + name: direzione + in: query + required: false + description: Identificativo interno all'ente, match esatto. + schema: + type: string + + DivisioneFilter: + name: divisione + in: query + required: false + description: Identificativo interno all'ente, match esatto. + schema: + type: string + IuvFilter: name: iuv in: query diff --git a/src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java b/src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java new file mode 100644 index 0000000..96f3146 --- /dev/null +++ b/src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java @@ -0,0 +1,93 @@ +package it.govpay.console.pendenza; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.Clock; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import it.govpay.console.model.StatoPendenza; + +class PendenzaMapperTest { + + private static final Instant NOW = Instant.parse("2026-06-15T10:00:00Z"); + + private final PendenzaMapper mapper = new PendenzaMapper(Clock.fixed(NOW, ZoneOffset.UTC)); + + @Nested + @DisplayName("mapStato - mapping diretti") + class MappingDiretti { + + @ParameterizedTest + @CsvSource({ + "ESEGUITA, PAGATA", + "ESEGUITO, PAGATA", + "ESEGUITO_ALTRO_CANALE, PAGATA", + "ESEGUITO_SENZA_RPT, PAGATA", + "ESEGUITA_PARZIALE, PAGATA_PARZIALE", + "ESEGUITO_PARZIALE, PAGATA_PARZIALE", + "INCASSATA, RICONCILIATA", + "INCASSATO, RICONCILIATA", + "ANNULLATA, ANNULLATA", + "ANNULLATO, ANNULLATA", + "ANOMALA, ANOMALA", + "ANOMALO, ANOMALA" + }) + @DisplayName("Stati grezzi V1 mappati sul valore V2 atteso, senza dipendenza da dataScadenza") + void mappaStatiDiretti(String statoV1, StatoPendenza atteso) { + assertEquals(atteso, mapper.mapStato(statoV1, null)); + } + + @Test + @DisplayName("Stato sconosciuto -> ANOMALA (fallback)") + void statoSconosciuto() { + assertEquals(StatoPendenza.ANOMALA, mapper.mapStato("QUALCOSA_DI_INESISTENTE", null)); + } + + @Test + @DisplayName("Stato null -> null") + void statoNull() { + assertNull(mapper.mapStato(null, null)); + } + } + + @Nested + @DisplayName("mapStato - derivazione SCADUTA") + class DerivazioneScaduta { + + @Test + @DisplayName("NON_ESEGUITO con dataScadenza passata -> SCADUTA") + void nonEseguitoScaduto() { + OffsetDateTime scadenzaPassata = OffsetDateTime.ofInstant(NOW, ZoneOffset.UTC).minusDays(1); + assertEquals(StatoPendenza.SCADUTA, mapper.mapStato("NON_ESEGUITO", scadenzaPassata)); + } + + @Test + @DisplayName("NON_ESEGUITO con dataScadenza futura -> NON_PAGATA") + void nonEseguitoNonScaduto() { + OffsetDateTime scadenzaFutura = OffsetDateTime.ofInstant(NOW, ZoneOffset.UTC).plusDays(1); + assertEquals(StatoPendenza.NON_PAGATA, mapper.mapStato("NON_ESEGUITO", scadenzaFutura)); + } + + @Test + @DisplayName("NON_ESEGUITO senza dataScadenza -> NON_PAGATA") + void nonEseguitoSenzaScadenza() { + assertEquals(StatoPendenza.NON_PAGATA, mapper.mapStato("NON_ESEGUITO", null)); + } + + @Test + @DisplayName("Stati diversi da NON_ESEGUITO non sono influenzati da dataScadenza") + void altriStatiIgnoranoScadenza() { + OffsetDateTime scadenzaPassata = OffsetDateTime.ofInstant(NOW, ZoneOffset.UTC).minusDays(1); + assertEquals(StatoPendenza.PAGATA, mapper.mapStato("ESEGUITO", scadenzaPassata)); + } + } +} From df86022dc2c4ec059cbc56055f95690a80bd87f1 Mon Sep 17 00:00:00 2001 From: link Date: Fri, 28 Aug 2026 17:16:29 +0200 Subject: [PATCH 2/4] Aggiunti filtri ricerca pendenze --- .../console/pendenza/PendenzaController.java | 23 +- .../PendenzaControllerIntegrationTest.java | 251 +++++++++++++++++- ...ndenzaCursorPaginationIntegrationTest.java | 16 +- 3 files changed, 278 insertions(+), 12 deletions(-) diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaController.java b/src/main/java/it/govpay/console/pendenza/PendenzaController.java index b1b28b8..c709503 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaController.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaController.java @@ -1,5 +1,6 @@ package it.govpay.console.pendenza; +import java.time.LocalDate; import java.util.List; import java.util.Set; @@ -15,6 +16,7 @@ import it.govpay.console.model.PendenzaExpand; import it.govpay.console.model.RicevutaSummary; import it.govpay.console.model.Soggetto; +import it.govpay.console.model.StatoPendenza; import it.govpay.console.ricevuta.RicevutaService; import it.govpay.console.soggetto.InformazioniDebitoreService; import it.govpay.console.web.ListQueryValidator; @@ -26,7 +28,8 @@ public class PendenzaController implements PendenzeApi { private static final Set LIST_PENDENZE_QUERY_PARAMS = Set.of( "page", "limit", "sort", "total", "cursor", - "idPendenza", "numeroAvviso", "idDominio", "identificativoDebitore"); + "idPendenza", "numeroAvviso", "idDominio", "identificativoDebitore", + "stato", "dataDa", "dataA", "iuv", "direzione", "divisione"); private static final Set GET_PENDENZA_QUERY_PARAMS = Set.of("expand"); @@ -66,14 +69,20 @@ public ResponseEntity listPendenze(Integer page, String idPendenza, String numeroAvviso, String idDominio, - String identificativoDebitore) { + String identificativoDebitore, + StatoPendenza stato, + LocalDate dataDa, + LocalDate dataA, + String iuv, + String direzione, + String divisione) { ListQueryValidator.rejectUnsupported(currentRequest, LIST_PENDENZE_QUERY_PARAMS); // cursor mode attivo se ?cursor=... e' presente nella query string, // anche con valore vuoto ("prima pagina cursor-mode", scope G issue #9). boolean cursorMode = ListQueryValidator.isCursorMode(currentRequest); if (cursorMode) { ListQueryValidator.rejectCursorIncompatible(currentRequest, - "dataOraUltimoAggiornamento DESC, id DESC"); + "dataCreazione DESC, id DESC"); } PendenzaListQuery query = new PendenzaListQuery( page == null ? 1 : page, @@ -84,7 +93,13 @@ public ResponseEntity listPendenze(Integer page, idPendenza, numeroAvviso, idDominio, - identificativoDebitore); + identificativoDebitore, + stato, + dataDa, + dataA, + iuv, + direzione, + divisione); return ResponseEntity.ok(service.list(query, currentRequest)); } diff --git a/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java b/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java index dac8c9e..ce9c574 100644 --- a/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java +++ b/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java @@ -320,7 +320,7 @@ void filterByIdPendenzaPartial() throws Exception { } @Test - void defaultSortByDataUltimoAggiornamentoDesc() throws Exception { + void defaultSortByDataCreazioneDesc() throws Exception { mvc.perform(get("/pendenze").param("idDominio", "11111111111") .with(httpBasic(PRINCIPAL, PASSWORD))) .andExpect(status().isOk()) @@ -329,6 +329,231 @@ void defaultSortByDataUltimoAggiornamentoDesc() throws Exception { "PEND-SCADUTA", "PEND-FUTURA"))); } + /** + * Issue #66 scope E: il default deve ordinare per {@code dataCreazione}, non + * piu' per {@code dataUltimoAggiornamento}. Fixture dedicata con i due campi + * deliberatamente in disaccordo (dataCreazione decrescente, dataUltimoAggiornamento + * costante): se il default tornasse a ordinare sul campo vecchio, l'ordine atteso + * non reggerebbe. + */ + @Test + void defaultSortUsesDataCreazioneNotDataUltimoAggiornamento() throws Exception { + Dominio dom = dominioRepository.findByCodDominio("11111111111").orElseThrow(); + Applicazione app = applicazioneRepository.findByCodApplicazione(APP_COD).orElseThrow(); + TipoVersamento tv = tipoVersamentoRepository.findByCodTipoVersamento("TARI").orElseThrow(); + TipoVersamentoDominio tvd = tipoVersamentoDominioRepository + .findByDominio_IdAndTipoVersamento_CodTipoVersamento(dom.getId(), "TARI").orElseThrow(); + OffsetDateTime stessoAggiornamento = OffsetDateTime.now(); + + for (int i = 1; i <= 3; i++) { + Versamento v = new Versamento(); + v.setCodVersamentoEnte("PEND-ORDINE-" + i); + v.setImportoTotale(10.0); + v.setStatoVersamento("NON_ESEGUITO"); + v.setDataCreazione(OffsetDateTime.now().minusDays(i)); + v.setDataOraUltimoAggiornamento(stessoAggiornamento); + v.setDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setDebitoreAnagrafica("Mario Rossi"); + v.setSrcDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setImportoPagato(0.0); + v.setAnomalo(false); + v.setAck(false); + v.setTipo("DOVUTO"); + v.setDominio(dom); + v.setApplicazione(app); + v.setTipoVersamento(tv); + v.setTipoVersamentoDominio(tvd); + versamentoRepository.save(v); + } + + mvc.perform(get("/pendenze").param("idPendenza", "PEND-ORDINE").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + contains("PEND-ORDINE-1", "PEND-ORDINE-2", "PEND-ORDINE-3"))); + } + + @Test + void filterByStatoNonPagataEsludeScaduta() throws Exception { + mvc.perform(get("/pendenze").param("idDominio", "11111111111").param("stato", "NON_PAGATA") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + containsInAnyOrder("PEND-A-001", "PEND-A-003", "PEND-FUTURA"))); + } + + @Test + void filterByStatoScadutaSoloNonEseguiteScadute() throws Exception { + mvc.perform(get("/pendenze").param("stato", "SCADUTA").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-SCADUTA"))); + } + + @Test + void filterByStatoPagata() throws Exception { + mvc.perform(get("/pendenze").param("stato", "PAGATA").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-002"))); + } + + /** + * Stati interni equivalenti a ESEGUITO (bug scoperto lavorando su #66, + * corretto anche in {@link PendenzaMapper#mapStato}): il filtro deve + * trovarli, non solo il mapper di output. + */ + @Test + void filterByStatoPagataIncludeStatiInterniEquivalenti() throws Exception { + Versamento altroCanale = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + altroCanale.setStatoVersamento("ESEGUITO_ALTRO_CANALE"); + versamentoRepository.save(altroCanale); + + Versamento senzaRpt = versamentoRepository.findDetail(APP_COD, "PEND-A-003").orElseThrow(); + senzaRpt.setStatoVersamento("ESEGUITO_SENZA_RPT"); + versamentoRepository.save(senzaRpt); + + mvc.perform(get("/pendenze").param("idDominio", "11111111111").param("stato", "PAGATA") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + containsInAnyOrder("PEND-A-001", "PEND-A-002", "PEND-A-003"))); + } + + @Test + void filterByStatoAnnullata() throws Exception { + mvc.perform(get("/pendenze").param("stato", "ANNULLATA").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-B-002"))); + } + + @Test + void filterByDataRangeSuDataCreazione() throws Exception { + // Fixture dedicata con offset in giorni: gli offset in ore del setup + // condiviso (1..11h) sono troppo vicini al confine di mezzanotte per + // dare un test deterministico indipendente dall'orario di esecuzione. + Dominio dom = dominioRepository.findByCodDominio("11111111111").orElseThrow(); + Applicazione app = applicazioneRepository.findByCodApplicazione(APP_COD).orElseThrow(); + TipoVersamento tv = tipoVersamentoRepository.findByCodTipoVersamento("TARI").orElseThrow(); + TipoVersamentoDominio tvd = tipoVersamentoDominioRepository + .findByDominio_IdAndTipoVersamento_CodTipoVersamento(dom.getId(), "TARI").orElseThrow(); + + salvaVersamentoCombinazione("PEND-RANGE-DENTRO", dom, app, tv, tvd, "NON_ESEGUITO", 1, null); + salvaVersamentoCombinazione("PEND-RANGE-FUORI", dom, app, tv, tvd, "NON_ESEGUITO", 10, null); + + java.time.LocalDate oggi = java.time.LocalDate.now(); + mvc.perform(get("/pendenze").param("idPendenza", "PEND-RANGE") + .param("dataDa", oggi.minusDays(2).toString()).param("dataA", oggi.toString()) + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-RANGE-DENTRO"))); + } + + @Test + void dataDaSuccessivaADataAReturns400() throws Exception { + mvc.perform(get("/pendenze") + .param("dataDa", "2026-06-15").param("dataA", "2026-06-01") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/problem+json")) + .andExpect(jsonPath("$.detail", org.hamcrest.Matchers.containsString("dataDa"))); + } + + @Test + void filterByIuv() throws Exception { + Versamento withIuv = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + withIuv.setIuvVersamento("IUV-TEST-001"); + versamentoRepository.save(withIuv); + + mvc.perform(get("/pendenze").param("iuv", "IUV-TEST-001").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-001"))); + } + + @Test + void filterByDirezione() throws Exception { + Versamento v = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + v.setDirezione("DIR-1"); + versamentoRepository.save(v); + + mvc.perform(get("/pendenze").param("direzione", "DIR-1").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-001"))); + } + + @Test + void filterByDivisione() throws Exception { + Versamento v = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + v.setDivisione("DIV-1"); + versamentoRepository.save(v); + + mvc.perform(get("/pendenze").param("divisione", "DIV-1").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-001"))); + } + + /** + * Combinazione richiesta dagli acceptance criteria: stato + range data. + * Fixture dedicata con offset in giorni (non ore, a differenza del resto + * del setup): un confine a livello di giorno su offset dell'ordine + * dell'ora e' intrinsecamente ambiguo rispetto al momento in cui gira il + * test (dipende da quanto manca a mezzanotte), quindi qui serve una + * separazione netta. + */ + @Test + void combinazioneStatoEDataRange() throws Exception { + Dominio dom = dominioRepository.findByCodDominio("11111111111").orElseThrow(); + Applicazione app = applicazioneRepository.findByCodApplicazione(APP_COD).orElseThrow(); + TipoVersamento tv = tipoVersamentoRepository.findByCodTipoVersamento("TARI").orElseThrow(); + TipoVersamentoDominio tvd = tipoVersamentoDominioRepository + .findByDominio_IdAndTipoVersamento_CodTipoVersamento(dom.getId(), "TARI").orElseThrow(); + + // Dentro il range richiesto, NON_PAGATA: deve comparire. + salvaVersamentoCombinazione("PEND-COMBO-DENTRO", dom, app, tv, tvd, "NON_ESEGUITO", 1, null); + // Dentro il range richiesto, ma PAGATA: non deve comparire (stato non combacia). + salvaVersamentoCombinazione("PEND-COMBO-PAGATA", dom, app, tv, tvd, "ESEGUITO", 1, null); + // Fuori dal range richiesto (10 giorni fa), NON_PAGATA: non deve comparire (data non combacia). + salvaVersamentoCombinazione("PEND-COMBO-FUORI-RANGE", dom, app, tv, tvd, "NON_ESEGUITO", 10, null); + + java.time.LocalDate oggi = java.time.LocalDate.now(); + mvc.perform(get("/pendenze").param("idPendenza", "PEND-COMBO") + .param("stato", "NON_PAGATA") + .param("dataDa", oggi.minusDays(2).toString()).param("dataA", oggi.toString()) + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-COMBO-DENTRO"))); + } + + private void salvaVersamentoCombinazione(String idPendenza, Dominio dom, Applicazione app, TipoVersamento tv, + TipoVersamentoDominio tvd, String statoV1, int giorniFa, + OffsetDateTime dataScadenza) { + Versamento v = new Versamento(); + v.setCodVersamentoEnte(idPendenza); + v.setImportoTotale(10.0); + v.setStatoVersamento(statoV1); + v.setDataCreazione(OffsetDateTime.now().minusDays(giorniFa)); + v.setDataOraUltimoAggiornamento(OffsetDateTime.now()); + v.setDataScadenza(dataScadenza); + v.setDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setDebitoreAnagrafica("Mario Rossi"); + v.setSrcDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setImportoPagato(0.0); + v.setAnomalo(false); + v.setAck(false); + v.setTipo("DOVUTO"); + v.setDominio(dom); + v.setApplicazione(app); + v.setTipoVersamento(tv); + v.setTipoVersamentoDominio(tvd); + versamentoRepository.save(v); + } + + /** ACL prevale anche sui nuovi filtri: dominio non visibile -> lista vuota, non 403. */ + @Test + void aclPrevaleSuNuoviFiltri() throws Exception { + mvc.perform(get("/pendenze").param("idDominio", "33333333333").param("stato", "NON_PAGATA") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", empty())); + } + @Test void customSortDirectionByDataScadenza() throws Exception { // dataScadenza ASC: prima i null (PEND-A-001/002/003), poi le date crescenti @@ -364,9 +589,31 @@ void unknownSortFieldReturns400() throws Exception { org.hamcrest.Matchers.containsString("bogusField"))); } + /** Issue #66 scope E: la chiave rinominata da dataCaricamento non e' piu' riconosciuta. */ + @Test + void oldSortKeyDataCaricamentoReturns400() throws Exception { + mvc.perform(get("/pendenze").param("sort", "dataCaricamento") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/problem+json")) + .andExpect(jsonPath("$.detail", + org.hamcrest.Matchers.containsString("dataCaricamento"))); + } + + @Test + void newSortKeyDataCreazioneIsAccepted() throws Exception { + mvc.perform(get("/pendenze").param("idDominio", "11111111111").param("sort", "-dataCreazione") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + contains("PEND-A-001", "PEND-A-002", "PEND-A-003", + "PEND-SCADUTA", "PEND-FUTURA"))); + } + @Test void unsupportedQueryParamReturns400() throws Exception { - mvc.perform(get("/pendenze").param("stato", "PAGATA").with(httpBasic(PRINCIPAL, PASSWORD))) + // idA2A resta non supportato fino alla PR 76b (issue #66, scope B). + mvc.perform(get("/pendenze").param("idA2A", "APP1").with(httpBasic(PRINCIPAL, PASSWORD))) .andExpect(status().isBadRequest()) .andExpect(content().contentType("application/problem+json")) .andExpect(jsonPath("$.detail", diff --git a/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java b/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java index 82db0c2..b2eed18 100644 --- a/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java +++ b/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java @@ -99,9 +99,12 @@ void setup() { tvd.setTipoVersamento(tv); tipoVersamentoDominioRepository.save(tvd); - // 7 pendenze con dataOraUltimoAggiornamento decrescente: PEND-1 piu' recente - // (offset 0 ore), PEND-7 piu' vecchia (offset 6 ore). Cursor mode → DESC, - // quindi i risultati arriveranno PEND-1, PEND-2, ..., PEND-7. + // 7 pendenze con dataCreazione decrescente: PEND-1 piu' recente (offset 0 + // ore), PEND-7 piu' vecchia (offset 6 ore). Cursor mode → DESC, quindi i + // risultati arriveranno PEND-1, PEND-2, ..., PEND-7. dataOraUltimoAggiornamento + // e' tenuta deliberatamente uguale per tutte (non decrescente): se il cursor + // ordinasse ancora su quel campo (bug di regressione della issue #66), le + // pendenze risulterebbero tutte in parita' e l'ordine atteso non reggerebbe. for (int i = 1; i <= 7; i++) { newPendenza("PEND-" + i, dom, app, tv, tvd, i - 1); } @@ -118,9 +121,10 @@ private void newPendenza(String idPendenza, Dominio dom, Applicazione app, // sessione con la precisione di now() (nanos su Linux) mentre il DB tronca, // sfasando il keyset del cursor (off-by-one al confine pagina). Senza sub- // precisione il valore in-memory coincide con quello persistito. - OffsetDateTime ts = OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS).minusHours(hoursAgo); - v.setDataCreazione(ts); - v.setDataOraUltimoAggiornamento(ts); + OffsetDateTime now = OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS); + v.setDataCreazione(now.minusHours(hoursAgo)); + // Deliberatamente uguale per tutte le righe: vedi il commento nel setup(). + v.setDataOraUltimoAggiornamento(now); v.setDebitoreIdentificativo("RSSMRA80A01H501U"); v.setDebitoreAnagrafica("Mario Rossi"); v.setSrcDebitoreIdentificativo("RSSMRA80A01H501U"); From 06d196b9da4a1128f65d46a4c7852a91413c7bb1 Mon Sep 17 00:00:00 2001 From: link Date: Fri, 28 Aug 2026 17:51:18 +0200 Subject: [PATCH 3/4] Completata la issue #66 --- .../console/pendenza/PendenzaController.java | 11 +- .../console/pendenza/PendenzaListQuery.java | 5 +- .../console/pendenza/PendenzaService.java | 32 +++- .../pendenza/PendenzaSpecifications.java | 15 ++ .../console/web/ProblemExceptionHandler.java | 8 + src/main/resources/openapi/openapi.yaml | 31 +++- .../PendenzaControllerIntegrationTest.java | 156 +++++++++++++++++- 7 files changed, 246 insertions(+), 12 deletions(-) diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaController.java b/src/main/java/it/govpay/console/pendenza/PendenzaController.java index c709503..17210d1 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaController.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaController.java @@ -29,7 +29,8 @@ public class PendenzaController implements PendenzeApi { private static final Set LIST_PENDENZE_QUERY_PARAMS = Set.of( "page", "limit", "sort", "total", "cursor", "idPendenza", "numeroAvviso", "idDominio", "identificativoDebitore", - "stato", "dataDa", "dataA", "iuv", "direzione", "divisione"); + "stato", "dataDa", "dataA", "iuv", "direzione", "divisione", + "idA2A", "idTipoPendenza"); private static final Set GET_PENDENZA_QUERY_PARAMS = Set.of("expand"); @@ -75,7 +76,9 @@ public ResponseEntity listPendenze(Integer page, LocalDate dataA, String iuv, String direzione, - String divisione) { + String divisione, + String idA2A, + List idTipoPendenza) { ListQueryValidator.rejectUnsupported(currentRequest, LIST_PENDENZE_QUERY_PARAMS); // cursor mode attivo se ?cursor=... e' presente nella query string, // anche con valore vuoto ("prima pagina cursor-mode", scope G issue #9). @@ -99,7 +102,9 @@ public ResponseEntity listPendenze(Integer page, dataA, iuv, direzione, - divisione); + divisione, + idA2A, + idTipoPendenza); return ResponseEntity.ok(service.list(query, currentRequest)); } diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java b/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java index c8b5140..596040f 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaListQuery.java @@ -1,6 +1,7 @@ package it.govpay.console.pendenza; import java.time.LocalDate; +import java.util.List; import it.govpay.console.model.StatoPendenza; @@ -19,5 +20,7 @@ public record PendenzaListQuery( LocalDate dataA, String iuv, String direzione, - String divisione) { + String divisione, + String idA2A, + List idTipoPendenza) { } diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaService.java b/src/main/java/it/govpay/console/pendenza/PendenzaService.java index 28013b2..a55b2f1 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaService.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaService.java @@ -51,6 +51,8 @@ public class PendenzaService { public static final String AZIONE_AUDIT_RICERCA = "PENDENZE_RICERCA_PER_DEBITORE"; + private static final int MAX_ID_TIPO_PENDENZA = 50; + private final VersamentoRepository repository; private final PendenzaMapper mapper; private final PendenzaLinksBuilder linksBuilder; @@ -104,16 +106,18 @@ public Pendenza get(String idA2A, String idPendenza, Set expand) public ListPendenze200Response list(PendenzaListQuery query, HttpServletRequest request) { OperatoreCorrente operatore = currentOperatorService.get(); log.debug("listPendenze filtri[idPendenza={}, numeroAvviso={}, idDominio={}, identificativoDebitore={}, " - + "stato={}, dataDa={}, dataA={}, iuv={}, direzione={}, divisione={}], " - + "page={}, limit={}, sort={}, total={}, cursor={}, operatore={}", + + "stato={}, dataDa={}, dataA={}, iuv={}, direzione={}, divisione={}, idA2A={}, " + + "idTipoPendenza={}], page={}, limit={}, sort={}, total={}, cursor={}, operatore={}", query.idPendenza(), query.numeroAvviso(), query.idDominio(), query.identificativoDebitore(), query.stato(), query.dataDa(), query.dataA(), query.iuv(), query.direzione(), query.divisione(), + query.idA2A(), query.idTipoPendenza(), query.page(), query.limit(), query.sort(), query.total(), query.cursor() != null, operatore.principal()); if (query.dataDa() != null && query.dataA() != null && query.dataDa().isAfter(query.dataA())) { throw new BadRequestException("'dataDa' non puo' essere successiva a 'dataA'."); } + List idTipoPendenza = normalizeIdTipoPendenza(query.idTipoPendenza()); // Spring Data JPA 4.x: Specification.allOf rifiuta null. Filtriamo i predicati assenti. Specification spec = Specification.allOf( @@ -128,6 +132,8 @@ public ListPendenze200Response list(PendenzaListQuery query, HttpServletRequest PendenzaSpecifications.iuvExact(query.iuv()), PendenzaSpecifications.direzioneExact(query.direzione()), PendenzaSpecifications.divisioneExact(query.divisione()), + PendenzaSpecifications.idA2AExact(query.idA2A()), + PendenzaSpecifications.idTipoPendenzaIn(idTipoPendenza), PendenzaSpecifications.visibiliPerOperatore(operatore)) .filter(java.util.Objects::nonNull) .toList()); @@ -162,6 +168,28 @@ public ListPendenze200Response list(PendenzaListQuery query, HttpServletRequest return response; } + /** + * {@code null} se il parametro non e' presente; altrimenti rimuove i valori + * vuoti (elementi CSV consecutivi, es. {@code idTipoPendenza=,,}) e valida + * i vincoli dell'OpenAPI: lista risultante non vuota, al massimo + * {@value #MAX_ID_TIPO_PENDENZA} elementi. + */ + private List normalizeIdTipoPendenza(List raw) { + if (raw == null) { + return null; + } + List normalized = raw.stream().filter(v -> v != null && !v.isBlank()).toList(); + if (normalized.isEmpty()) { + throw new BadRequestException( + "'idTipoPendenza' non puo' essere vuoto o composto solo da separatori."); + } + if (normalized.size() > MAX_ID_TIPO_PENDENZA) { + throw new BadRequestException( + "'idTipoPendenza' supporta al massimo " + MAX_ID_TIPO_PENDENZA + " elementi."); + } + return normalized; + } + private List listOffsetMode(Specification spec, PendenzaListQuery query, ListPendenze200Response response) { diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java b/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java index 07892b9..752c35f 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java @@ -85,6 +85,21 @@ public static Specification divisioneExact(String value) { return (root, q, cb) -> cb.equal(root.get("divisione"), value); } + public static Specification idA2AExact(String value) { + if (value == null || value.isBlank()) { + return null; + } + return (root, q, cb) -> cb.equal(root.get("applicazione").get("codApplicazione"), value); + } + + /** Semantica OR fra i valori: {@code versamenti.id_tipo_versamento IN (...)}. */ + public static Specification idTipoPendenzaIn(List values) { + if (values == null || values.isEmpty()) { + return null; + } + return (root, q, cb) -> root.get("tipoVersamento").get("codTipoVersamento").in(values); + } + private static final List RAW_PAGATA = List.of("ESEGUITA", "ESEGUITO", "PAGATA", "PAGATO", "ESEGUITO_ALTRO_CANALE", "ESEGUITO_SENZA_RPT"); private static final List RAW_NON_ESEGUITO = diff --git a/src/main/java/it/govpay/console/web/ProblemExceptionHandler.java b/src/main/java/it/govpay/console/web/ProblemExceptionHandler.java index 65f0097..5b828e5 100644 --- a/src/main/java/it/govpay/console/web/ProblemExceptionHandler.java +++ b/src/main/java/it/govpay/console/web/ProblemExceptionHandler.java @@ -2,7 +2,9 @@ import java.net.URI; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -85,6 +87,12 @@ public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchExce HttpServletRequest request) { String detail = "Valore non valido per il parametro '" + ex.getName() + "': " + (ex.getValue() != null ? ex.getValue() : ""); + Class requiredType = ex.getRequiredType(); + if (requiredType != null && requiredType.isEnum()) { + detail += ". Valori ammessi: " + Arrays.stream(requiredType.getEnumConstants()) + .map(Object::toString) + .collect(Collectors.joining(", ")); + } return build(HttpStatus.BAD_REQUEST, detail, request, null, ex); } diff --git a/src/main/resources/openapi/openapi.yaml b/src/main/resources/openapi/openapi.yaml index 295b7b7..92b4759 100644 --- a/src/main/resources/openapi/openapi.yaml +++ b/src/main/resources/openapi/openapi.yaml @@ -1167,12 +1167,15 @@ paths: - `stato` — match esatto sull'enum `StatoPendenza`; - `dataDa`/`dataA` — intervallo incluso sulla data di creazione della pendenza; - `iuv` — match esatto; - - `direzione`, `divisione` — match esatto. + - `direzione`, `divisione` — match esatto; + - `idA2A` — match esatto sul gestionale proprietario; + - `idTipoPendenza` — elenco CSV di codici tipologia, semantica OR, max 50 elementi. - Tutti i filtri sono opzionali e ortogonali (AND). I filtri V1 non - ancora supportati (`idA2A`, `idTipoPendenza`) ritornano 400 problem+json. + Tutti i filtri sono opzionali e ortogonali (AND). - `dataDa` successivo a `dataA` → 400 problem+json. + `dataDa` successivo a `dataA` → 400 problem+json. `idTipoPendenza` + vuoto, con soli separatori o oltre il limite di 50 elementi → 400 + problem+json; un codice inesistente nella lista restringe senza errore. ACL: il filtro per dominio dell'operatore corrente viene applicato silenziosamente; un operatore senza visibilita' su alcun dominio riceve @@ -1204,6 +1207,8 @@ paths: - $ref: '#/components/parameters/IuvFilter' - $ref: '#/components/parameters/DirezioneFilter' - $ref: '#/components/parameters/DivisioneFilter' + - $ref: '#/components/parameters/IdA2A' + - $ref: '#/components/parameters/PendenzaIdTipoPendenzaFilter' responses: '200': description: Lista paginata di pendenze. @@ -12900,6 +12905,24 @@ components: schema: type: string + PendenzaIdTipoPendenzaFilter: + name: idTipoPendenza + in: query + required: false + description: | + Elenco (CSV) di codici tipologia pendenza, semantica OR fra i valori + (in AND con gli altri filtri): `?idTipoPendenza=a,b` restituisce le + pendenze di tipo `a` **o** `b`. Un valore singolo e' trattato come + lista di un elemento. Un codice non esistente non e' un errore: la + lista si restringe e basta. Massimo 50 elementi. Lista vuota o + composta solo da separatori (`?idTipoPendenza=`, `?idTipoPendenza=,,`) + → 400 problem+json. + schema: + type: array + items: + type: string + explode: false + IdDominioFilter: name: idDominio in: query diff --git a/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java b/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java index ce9c574..95d1aec 100644 --- a/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java +++ b/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java @@ -417,6 +417,17 @@ void filterByStatoPagataIncludeStatiInterniEquivalenti() throws Exception { containsInAnyOrder("PEND-A-001", "PEND-A-002", "PEND-A-003"))); } + /** Acceptance criteria issue #66: valore V1 non valido per 'stato' elenca i valori ammessi. */ + @Test + void statoValoreInvalidoElencaValoriAmmessi() throws Exception { + mvc.perform(get("/pendenze").param("stato", "BOGUS").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/problem+json")) + .andExpect(jsonPath("$.detail", org.hamcrest.Matchers.containsString("Valori ammessi"))) + .andExpect(jsonPath("$.detail", org.hamcrest.Matchers.containsString("NON_PAGATA"))) + .andExpect(jsonPath("$.detail", org.hamcrest.Matchers.containsString("PAGATA"))); + } + @Test void filterByStatoAnnullata() throws Exception { mvc.perform(get("/pendenze").param("stato", "ANNULLATA").with(httpBasic(PRINCIPAL, PASSWORD))) @@ -612,8 +623,9 @@ void newSortKeyDataCreazioneIsAccepted() throws Exception { @Test void unsupportedQueryParamReturns400() throws Exception { - // idA2A resta non supportato fino alla PR 76b (issue #66, scope B). - mvc.perform(get("/pendenze").param("idA2A", "APP1").with(httpBasic(PRINCIPAL, PASSWORD))) + // Issue #66 scope C: esclusione esplicita, non deve mai comparire nell'OpenAPI. + mvc.perform(get("/pendenze").param("mostraSpontaneiNonPagati", "true") + .with(httpBasic(PRINCIPAL, PASSWORD))) .andExpect(status().isBadRequest()) .andExpect(content().contentType("application/problem+json")) .andExpect(jsonPath("$.detail", @@ -763,4 +775,144 @@ void nonEseguitoNotYetExpiredIsMappedToNonPagata() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.results[0].stato", is("NON_PAGATA"))); } + + // ---- Issue #66 scope B: idA2A, idTipoPendenza ---- + + @Test + void filterByIdA2AExact() throws Exception { + Applicazione appB = new Applicazione(); + appB.setCodApplicazione("APP-B"); + applicazioneRepository.save(appB); + + Versamento v = versamentoRepository.findDetail(APP_COD, "PEND-A-002").orElseThrow(); + v.setApplicazione(appB); + versamentoRepository.save(v); + + mvc.perform(get("/pendenze").param("idA2A", "APP-B").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-002"))); + } + + /** Acceptance criteria issue #66: combinazione idDominio+idA2A+stato. */ + @Test + void combinazioneIdDominioIdA2AEStato() throws Exception { + Applicazione appB = new Applicazione(); + appB.setCodApplicazione("APP-B"); + applicazioneRepository.save(appB); + + Versamento v = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + v.setApplicazione(appB); + versamentoRepository.save(v); + + mvc.perform(get("/pendenze").param("idDominio", "11111111111") + .param("idA2A", "APP-B").param("stato", "NON_PAGATA") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-001"))); + } + + @Test + void filterByIdTipoPendenzaMultiploUnisceIRisultati() throws Exception { + Dominio dom = dominioRepository.findByCodDominio("11111111111").orElseThrow(); + Applicazione app = applicazioneRepository.findByCodApplicazione(APP_COD).orElseThrow(); + TipoVersamento imu = creaTipoVersamento("IMU"); + TipoVersamentoDominio tvdImu = newTvd(dom, imu); + salvaVersamentoTipo("PEND-IMU-001", dom, app, imu, tvdImu, "NON_ESEGUITO"); + + // Solo IMU: deve isolare la pendenza IMU, escludendo le 5 pendenze TARI del dominio A. + mvc.perform(get("/pendenze").param("idDominio", "11111111111").param("idTipoPendenza", "IMU") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-IMU-001"))); + + // TARI,IMU: unione, tutte e 6 le pendenze del dominio A. + mvc.perform(get("/pendenze").param("idDominio", "11111111111").param("idTipoPendenza", "TARI,IMU") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + containsInAnyOrder("PEND-A-001", "PEND-A-002", "PEND-A-003", + "PEND-SCADUTA", "PEND-FUTURA", "PEND-IMU-001"))); + } + + @Test + void filterByIdTipoPendenzaConValoreInesistenteRestringeSenzaErrore() throws Exception { + mvc.perform(get("/pendenze").param("idDominio", "11111111111").param("idTipoPendenza", "TARI,BOGUS") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + containsInAnyOrder("PEND-A-001", "PEND-A-002", "PEND-A-003", + "PEND-SCADUTA", "PEND-FUTURA"))); + } + + @Test + void idTipoPendenzaVuotoOSoloSeparatoriReturns400() throws Exception { + mvc.perform(get("/pendenze").param("idTipoPendenza", "").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/problem+json")) + .andExpect(jsonPath("$.detail", org.hamcrest.Matchers.containsString("idTipoPendenza"))); + + mvc.perform(get("/pendenze").param("idTipoPendenza", ",,").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/problem+json")) + .andExpect(jsonPath("$.detail", org.hamcrest.Matchers.containsString("idTipoPendenza"))); + } + + @Test + void idTipoPendenzaOltreLimiteMassimoReturns400() throws Exception { + String troppi = java.util.stream.IntStream.rangeClosed(1, 51) + .mapToObj(i -> "T" + i) + .collect(java.util.stream.Collectors.joining(",")); + + mvc.perform(get("/pendenze").param("idTipoPendenza", troppi).with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType("application/problem+json")) + .andExpect(jsonPath("$.detail", org.hamcrest.Matchers.containsString("idTipoPendenza"))); + } + + /** Acceptance criteria issue #66: idTipoPendenza multiplo (OR) in AND con stato. */ + @Test + void combinazioneIdTipoPendenzaMultiploEStato() throws Exception { + Dominio dom = dominioRepository.findByCodDominio("11111111111").orElseThrow(); + Applicazione app = applicazioneRepository.findByCodApplicazione(APP_COD).orElseThrow(); + TipoVersamento imu = creaTipoVersamento("IMU"); + TipoVersamentoDominio tvdImu = newTvd(dom, imu); + salvaVersamentoTipo("PEND-IMU-NON-PAGATA", dom, app, imu, tvdImu, "NON_ESEGUITO"); + salvaVersamentoTipo("PEND-IMU-PAGATA", dom, app, imu, tvdImu, "ESEGUITO"); + + mvc.perform(get("/pendenze").param("idDominio", "11111111111") + .param("idTipoPendenza", "TARI,IMU").param("stato", "NON_PAGATA") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + containsInAnyOrder("PEND-A-001", "PEND-A-003", "PEND-FUTURA", "PEND-IMU-NON-PAGATA"))); + } + + private TipoVersamento creaTipoVersamento(String cod) { + TipoVersamento tv = new TipoVersamento(); + tv.setCodTipoVersamento(cod); + tv.setDescrizione(cod); + return tipoVersamentoRepository.save(tv); + } + + private Versamento salvaVersamentoTipo(String idPendenza, Dominio dom, Applicazione app, + TipoVersamento tv, TipoVersamentoDominio tvd, String statoV1) { + Versamento v = new Versamento(); + v.setCodVersamentoEnte(idPendenza); + v.setImportoTotale(10.0); + v.setStatoVersamento(statoV1); + v.setDataCreazione(OffsetDateTime.now()); + v.setDataOraUltimoAggiornamento(OffsetDateTime.now()); + v.setDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setDebitoreAnagrafica("Mario Rossi"); + v.setSrcDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setImportoPagato(0.0); + v.setAnomalo(false); + v.setAck(false); + v.setTipo("DOVUTO"); + v.setDominio(dom); + v.setApplicazione(app); + v.setTipoVersamento(tv); + v.setTipoVersamentoDominio(tvd); + return versamentoRepository.save(v); + } } From 08c1695e2e948674ad825a2cc165caf27d61d708 Mon Sep 17 00:00:00 2001 From: link Date: Fri, 28 Aug 2026 18:19:42 +0200 Subject: [PATCH 4/4] Verifiche post implementazione issue --- .../console/pendenza/PendenzaMapper.java | 22 +- .../console/pendenza/PendenzaService.java | 7 + .../pendenza/PendenzaSpecifications.java | 86 +++++--- .../pendenza/StatoVersamentoMapping.java | 78 +++++++ .../PendenzaControllerIntegrationTest.java | 50 +++++ ...ndenzaCursorPaginationIntegrationTest.java | 206 ++++++++++++++++++ .../console/pendenza/PendenzaMapperTest.java | 11 + 7 files changed, 410 insertions(+), 50 deletions(-) create mode 100644 src/main/java/it/govpay/console/pendenza/StatoVersamentoMapping.java diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java b/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java index add6a97..72a5241 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaMapper.java @@ -90,23 +90,11 @@ StatoPendenza mapStato(String statoV1, OffsetDateTime dataScadenza) { } private static StatoPendenza baseMapStato(String statoV1) { - // V1 usa varianti femminili/maschili; normalizziamo prima di mappare. - String normalized = statoV1.trim().toUpperCase(); - return switch (normalized) { - case "ESEGUITA", "ESEGUITO", "PAGATA", "PAGATO", "ESEGUITO_ALTRO_CANALE", "ESEGUITO_SENZA_RPT" -> - StatoPendenza.PAGATA; - case "NON_ESEGUITA", "NON_ESEGUITO", "NON_PAGATA", "NON_PAGATO" -> StatoPendenza.NON_PAGATA; - case "ESEGUITA_PARZIALE", "ESEGUITO_PARZIALE", "PAGATA_PARZIALE", "PAGATO_PARZIALE" -> - StatoPendenza.PAGATA_PARZIALE; - case "INCASSATA", "INCASSATO", "RICONCILIATA", "RICONCILIATO" -> StatoPendenza.RICONCILIATA; - case "ANNULLATA", "ANNULLATO" -> StatoPendenza.ANNULLATA; - case "SCADUTA", "SCADUTO" -> StatoPendenza.SCADUTA; - case "ANOMALA", "ANOMALO" -> StatoPendenza.ANOMALA; - default -> { - log.warn("Stato versamento V1 sconosciuto, mappato a ANOMALA: {}", statoV1); - yield StatoPendenza.ANOMALA; - } - }; + StatoPendenza mapped = StatoVersamentoMapping.baseMap(statoV1); + if (mapped == StatoPendenza.ANOMALA && !StatoVersamentoMapping.isRiconosciuto(statoV1)) { + log.warn("Stato versamento V1 sconosciuto, mappato a ANOMALA: {}", statoV1); + } + return mapped; } static DominioRef mapDominio(Dominio dominio) { diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaService.java b/src/main/java/it/govpay/console/pendenza/PendenzaService.java index a55b2f1..2d9fa02 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaService.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaService.java @@ -231,6 +231,13 @@ private List listOffsetMode(Specification spec, *

Se il cursor e' vuoto (caso "prima pagina cursor mode", attivato da * {@code ?cursor=} senza valore), il filtro keyset viene omesso e si * usano solo l'ordinamento e il limit. + * + *

Verifica indici (issue #66, non applicata: lo schema di {@code versamenti} + * e' condiviso col core, la migrazione va concordata a parte). Sul DDL V1 + * reale esiste solo {@code idx_vrs_data_creaz(data_creazione DESC)}, a singola + * colonna: non copre il tiebreak su {@code id} di questa query. Proposta: + * {@code CREATE INDEX idx_vrs_data_creaz_id ON versamenti (data_creazione DESC, id DESC);} + * (sostituirebbe {@code idx_vrs_data_creaz}, che ne e' un prefisso). */ private List listCursorMode(Specification spec, PendenzaListQuery query, diff --git a/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java b/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java index 752c35f..a817b7b 100644 --- a/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java +++ b/src/main/java/it/govpay/console/pendenza/PendenzaSpecifications.java @@ -71,6 +71,15 @@ public static Specification iuvExact(String value) { return (root, q, cb) -> cb.equal(root.get("iuvVersamento"), value); } + /** + * Verifica indici (issue #66, non applicata: lo schema di {@code versamenti} + * e' condiviso col core, la migrazione va concordata a parte). Sul DDL V1 + * reale, {@code direzione}/{@code divisione} non hanno alcun indice: se + * usati in isolamento (senza {@code idDominio}, gia' indicizzato) il filtro + * fa scan completa. Proposta se l'uso reale risultera' selettivo: + * {@code CREATE INDEX idx_vrs_direzione ON versamenti (direzione);} + * {@code CREATE INDEX idx_vrs_divisione ON versamenti (divisione);} + */ public static Specification direzioneExact(String value) { if (value == null || value.isBlank()) { return null; @@ -85,6 +94,13 @@ public static Specification divisioneExact(String value) { return (root, q, cb) -> cb.equal(root.get("divisione"), value); } + /** + * Verifica indici (issue #66, non applicata: vedi nota su {@link #direzioneExact}). + * {@code id_applicazione} non ha un indice con se stesso come colonna leading + * (solo 2a colonna in {@code idx_vrs_id_pendenza(cod_versamento_ente, id_applicazione)}): + * un {@code idA2A} senza {@code idDominio} fa scan. Proposta: + * {@code CREATE INDEX idx_vrs_id_applicazione ON versamenti (id_applicazione);} + */ public static Specification idA2AExact(String value) { if (value == null || value.isBlank()) { return null; @@ -92,7 +108,15 @@ public static Specification idA2AExact(String value) { return (root, q, cb) -> cb.equal(root.get("applicazione").get("codApplicazione"), value); } - /** Semantica OR fra i valori: {@code versamenti.id_tipo_versamento IN (...)}. */ + /** + * Semantica OR fra i valori: {@code versamenti.id_tipo_versamento IN (...)}. + * + *

Verifica indici (issue #66, non applicata: vedi nota su {@link #direzioneExact}). + * {@code id_tipo_versamento} non ha un indice con se stesso come colonna leading + * (solo 2a colonna in {@code idx_vrs_auth(id_dominio, id_tipo_versamento, id_uo)}): + * un {@code idTipoPendenza} senza {@code idDominio} fa scan. Proposta: + * {@code CREATE INDEX idx_vrs_id_tipo_versamento ON versamenti (id_tipo_versamento);} + */ public static Specification idTipoPendenzaIn(List values) { if (values == null || values.isEmpty()) { return null; @@ -100,52 +124,48 @@ public static Specification idTipoPendenzaIn(List values) { return (root, q, cb) -> root.get("tipoVersamento").get("codTipoVersamento").in(values); } - private static final List RAW_PAGATA = - List.of("ESEGUITA", "ESEGUITO", "PAGATA", "PAGATO", "ESEGUITO_ALTRO_CANALE", "ESEGUITO_SENZA_RPT"); - private static final List RAW_NON_ESEGUITO = - List.of("NON_ESEGUITA", "NON_ESEGUITO", "NON_PAGATA", "NON_PAGATO"); - private static final List RAW_PAGATA_PARZIALE = - List.of("ESEGUITA_PARZIALE", "ESEGUITO_PARZIALE", "PAGATA_PARZIALE", "PAGATO_PARZIALE", "PARZIALMENTE_ESEGUITO"); - private static final List RAW_RICONCILIATA = - List.of("INCASSATA", "INCASSATO", "RICONCILIATA", "RICONCILIATO"); - private static final List RAW_ANNULLATA = List.of("ANNULLATA", "ANNULLATO"); - private static final List RAW_ANOMALA = List.of("ANOMALA", "ANOMALO"); - /** - * Traduce lo stato V2 sul/i valore/i grezzo/i di {@code stato_versamento}. + * Traduce lo stato V2 sul/i valore/i grezzo/i di {@code stato_versamento}, + * condividendo i gruppi con {@link PendenzaMapper} tramite {@link StatoVersamentoMapping} + * (fonte unica: le due derivazioni non possono piu' divergere silenziosamente). * V1 non e' consistente sul genere del valore grezzo (visto sia * {@code ESEGUITO} che {@code ESEGUITA} in dati reali): ogni stato include - * tutte le varianti riconosciute, esattamente come gia' fa - * {@link PendenzaMapper#mapStato} in lettura — un filtro piu' stretto - * lascerebbe fuori righe che l'output mostra correttamente mappate. + * tutte le varianti riconosciute — un filtro piu' stretto lascerebbe fuori + * righe che l'output mostra correttamente mappate. * {@code PAGATA} include anche gli stati interni equivalenti * ({@code ESEGUITO_ALTRO_CANALE}, {@code ESEGUITO_SENZA_RPT}). - * {@code NON_PAGATA} e {@code SCADUTA} condividono lo stesso valore grezzo - * e si distinguono solo per {@code data_scadenza} rispetto a {@code now} — + * {@code SCADUTA} unisce il valore letterale ({@code SCADUTA}/{@code SCADUTO}) + * con la derivazione da {@code NON_ESEGUITO} + {@code data_scadenza} passata — * stessa semantica di V1 (V1 {@code PendenzeDAO}: - * {@code AbilitaFiltroNonScaduto}/{@code AbilitaFiltroScaduto}), non un - * {@code equal} semplice. Righe con un valore grezzo non riconosciuto (che - * il mapper di output marca comunque {@code ANOMALA} per default) non sono - * raggiunte da nessuno stato filtrabile: limite noto, non un requisito di - * questa issue. + * {@code AbilitaFiltroNonScaduto}/{@code AbilitaFiltroScaduto}) per la parte + * derivata. {@code ANOMALA} e' il catch-all del mapper (valori letterali + * {@code ANOMALA}/{@code ANOMALO} + qualunque valore non riconosciuto): il + * filtro lo esprime come {@code NOT IN} sul complemento, non un elenco chiuso, + * altrimenti una riga con stato grezzo ignoto sarebbe mostrata ANOMALA in + * output ma irraggiungibile da {@code ?stato=ANOMALA}. */ public static Specification statoExact(StatoPendenza stato, OffsetDateTime now) { if (stato == null) { return null; } return switch (stato) { - case PAGATA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_PAGATA); - case PAGATA_PARZIALE -> (root, q, cb) -> root.get("statoVersamento").in(RAW_PAGATA_PARZIALE); - case RICONCILIATA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_RICONCILIATA); - case ANNULLATA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_ANNULLATA); - case ANOMALA -> (root, q, cb) -> root.get("statoVersamento").in(RAW_ANOMALA); + case PAGATA -> (root, q, cb) -> root.get("statoVersamento").in(StatoVersamentoMapping.PAGATA); + case PAGATA_PARZIALE -> (root, q, cb) -> + root.get("statoVersamento").in(StatoVersamentoMapping.PAGATA_PARZIALE); + case RICONCILIATA -> (root, q, cb) -> + root.get("statoVersamento").in(StatoVersamentoMapping.RICONCILIATA); + case ANNULLATA -> (root, q, cb) -> root.get("statoVersamento").in(StatoVersamentoMapping.ANNULLATA); + case ANOMALA -> (root, q, cb) -> + cb.not(root.get("statoVersamento").in(StatoVersamentoMapping.ALTRI_STATI_NOTI)); case NON_PAGATA -> (root, q, cb) -> cb.and( - root.get("statoVersamento").in(RAW_NON_ESEGUITO), + root.get("statoVersamento").in(StatoVersamentoMapping.NON_ESEGUITO), cb.or(cb.isNull(root.get("dataScadenza")), cb.greaterThanOrEqualTo(root.get("dataScadenza"), now))); - case SCADUTA -> (root, q, cb) -> cb.and( - root.get("statoVersamento").in(RAW_NON_ESEGUITO), - cb.isNotNull(root.get("dataScadenza")), - cb.lessThan(root.get("dataScadenza"), now)); + case SCADUTA -> (root, q, cb) -> cb.or( + root.get("statoVersamento").in(StatoVersamentoMapping.SCADUTA_LETTERALE), + cb.and( + root.get("statoVersamento").in(StatoVersamentoMapping.NON_ESEGUITO), + cb.isNotNull(root.get("dataScadenza")), + cb.lessThan(root.get("dataScadenza"), now))); }; } diff --git a/src/main/java/it/govpay/console/pendenza/StatoVersamentoMapping.java b/src/main/java/it/govpay/console/pendenza/StatoVersamentoMapping.java new file mode 100644 index 0000000..398b31d --- /dev/null +++ b/src/main/java/it/govpay/console/pendenza/StatoVersamentoMapping.java @@ -0,0 +1,78 @@ +package it.govpay.console.pendenza; + +import java.util.List; +import java.util.stream.Stream; + +import it.govpay.console.model.StatoPendenza; + +/** + * Fonte unica della traduzione fra {@code stato_versamento} (V1, stringa grezza, + * genere non garantito nei dati reali) e {@link StatoPendenza} (V2). Usata sia + * da {@link PendenzaMapper} (mapping in lettura) sia da {@link PendenzaSpecifications} + * (filtro {@code ?stato=}): le due derivazioni condividono questi gruppi invece di + * ridefinirli ciascuna per conto proprio, per non poter divergere silenziosamente. + * + *

{@code ANOMALA} non ha un proprio elenco chiuso di valori grezzi: e' il + * catch-all per tutto cio' che non rientra negli altri gruppi (compresi i + * letterali {@code ANOMALA}/{@code ANOMALO} e qualunque valore sconosciuto), + * esattamente come nel mapper. {@link #ALTRI_STATI_NOTI} espone il complemento, + * cosi' anche il filtro puo' esprimere lo stesso catch-all con un {@code NOT IN}. + */ +final class StatoVersamentoMapping { + + static final List PAGATA = + List.of("ESEGUITA", "ESEGUITO", "PAGATA", "PAGATO", "ESEGUITO_ALTRO_CANALE", "ESEGUITO_SENZA_RPT"); + static final List NON_ESEGUITO = + List.of("NON_ESEGUITA", "NON_ESEGUITO", "NON_PAGATA", "NON_PAGATO"); + static final List PAGATA_PARZIALE = List.of( + "ESEGUITA_PARZIALE", "ESEGUITO_PARZIALE", "PAGATA_PARZIALE", "PAGATO_PARZIALE", "PARZIALMENTE_ESEGUITO"); + static final List RICONCILIATA = + List.of("INCASSATA", "INCASSATO", "RICONCILIATA", "RICONCILIATO"); + static final List ANNULLATA = List.of("ANNULLATA", "ANNULLATO"); + static final List SCADUTA_LETTERALE = List.of("SCADUTA", "SCADUTO"); + static final List ANOMALA_LETTERALE = List.of("ANOMALA", "ANOMALO"); + + /** Unione di tutti i gruppi diversi da ANOMALA: il complemento per il catch-all. */ + static final List ALTRI_STATI_NOTI = Stream.of( + PAGATA, NON_ESEGUITO, PAGATA_PARZIALE, RICONCILIATA, ANNULLATA, SCADUTA_LETTERALE) + .flatMap(List::stream) + .toList(); + + private StatoVersamentoMapping() { + } + + /** + * Mapping diretto, senza la derivazione SCADUTA-da-{@code dataScadenza} (che + * dipende anche da {@code now} e resta responsabilita' del chiamante: vedi + * {@link PendenzaMapper#mapStato} per la lettura, {@link PendenzaSpecifications#statoExact} + * per il filtro). + */ + static StatoPendenza baseMap(String raw) { + String normalized = raw.trim().toUpperCase(); + if (PAGATA.contains(normalized)) { + return StatoPendenza.PAGATA; + } + if (NON_ESEGUITO.contains(normalized)) { + return StatoPendenza.NON_PAGATA; + } + if (PAGATA_PARZIALE.contains(normalized)) { + return StatoPendenza.PAGATA_PARZIALE; + } + if (RICONCILIATA.contains(normalized)) { + return StatoPendenza.RICONCILIATA; + } + if (ANNULLATA.contains(normalized)) { + return StatoPendenza.ANNULLATA; + } + if (SCADUTA_LETTERALE.contains(normalized)) { + return StatoPendenza.SCADUTA; + } + return StatoPendenza.ANOMALA; + } + + /** {@code true} se il valore grezzo rientra in un gruppo esplicito (incluso ANOMALA letterale). */ + static boolean isRiconosciuto(String raw) { + String normalized = raw.trim().toUpperCase(); + return ALTRI_STATI_NOTI.contains(normalized) || ANOMALA_LETTERALE.contains(normalized); + } +} diff --git a/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java b/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java index 95d1aec..1ecffb1 100644 --- a/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java +++ b/src/test/java/it/govpay/console/pendenza/PendenzaControllerIntegrationTest.java @@ -435,6 +435,56 @@ void filterByStatoAnnullata() throws Exception { .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-B-002"))); } + /** + * Il mapper mappa lo stato grezzo letterale SCADUTA/SCADUTO direttamente a + * SCADUTA, indipendentemente da data_scadenza: il filtro deve trovarla, + * non solo la variante derivata da NON_ESEGUITO + scadenza passata. + */ + @Test + void filterByStatoScadutaTrovaAncheIlValoreGrezzoLetterale() throws Exception { + Versamento v = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + v.setStatoVersamento("SCADUTA"); + v.setDataScadenza(null); + versamentoRepository.save(v); + + mvc.perform(get("/pendenze").param("stato", "SCADUTA").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", + containsInAnyOrder("PEND-A-001", "PEND-SCADUTA"))); + } + + /** + * PARZIALMENTE_ESEGUITO e' il nome V1 canonico dello stato (oltre alle + * varianti ESEGUITA_PARZIALE/ESEGUITO_PARZIALE): il mapper lo mostra come + * PAGATA_PARZIALE, quindi il filtro deve trovarlo con lo stesso nome. + */ + @Test + void filterByStatoPagataParzialeTrovaAncheParzialmenteEseguito() throws Exception { + Versamento v = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + v.setStatoVersamento("PARZIALMENTE_ESEGUITO"); + versamentoRepository.save(v); + + mvc.perform(get("/pendenze").param("stato", "PAGATA_PARZIALE").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-001"))); + } + + /** + * ANOMALA nel mapper e' il catch-all per qualunque valore grezzo non + * riconosciuto: il filtro deve trovare anche quelle righe, non solo i + * letterali ANOMALA/ANOMALO. + */ + @Test + void filterByStatoAnomalaTrovaAncheValoriGrezziSconosciuti() throws Exception { + Versamento v = versamentoRepository.findDetail(APP_COD, "PEND-A-001").orElseThrow(); + v.setStatoVersamento("QUALCOSA_DI_INESISTENTE"); + versamentoRepository.save(v); + + mvc.perform(get("/pendenze").param("stato", "ANOMALA").with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results[*].idPendenza", contains("PEND-A-001"))); + } + @Test void filterByDataRangeSuDataCreazione() throws Exception { // Fixture dedicata con offset in giorni: gli offset in ore del setup diff --git a/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java b/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java index b2eed18..f5d217a 100644 --- a/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java +++ b/src/test/java/it/govpay/console/pendenza/PendenzaCursorPaginationIntegrationTest.java @@ -187,6 +187,212 @@ void ultimaPaginaSenzaNextCursor() throws Exception { .andExpect(jsonPath("$.nextCursor").doesNotExist()); } + /** + * Le 7 pendenze di {@link #setup()} hanno tutte dataCreazione distinte: + * nessun test di questa classe esercita il ramo del keyset che confronta + * l'id quando data_creazione e' in parita' ({@code dataCreazione = :ts AND id < :id}). + * Tre righe con lo STESSO timestamp (piu' vecchio di ogni pendenza del + * setup, per non intersecarsi) forzano quel ramo: verifica sia l'ordine + * (id DESC come tiebreak) sia l'assenza di duplicati/perdite fra le pagine. + */ + @Test + void paginazioneConDataCreazioneUgualeUsaIdComeTiebreak() throws Exception { + Dominio dom = dominioRepository.findByCodDominio("77777777777").orElseThrow(); + Applicazione app = applicazioneRepository.findByCodApplicazione(APP_COD).orElseThrow(); + TipoVersamento tv = tipoVersamentoRepository.findByCodTipoVersamento("TARI").orElseThrow(); + TipoVersamentoDominio tvd = tipoVersamentoDominioRepository + .findByDominio_IdAndTipoVersamento_CodTipoVersamento(dom.getId(), "TARI").orElseThrow(); + + OffsetDateTime stessaData = OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS).minusDays(1); + for (int i = 1; i <= 3; i++) { + Versamento v = new Versamento(); + v.setCodVersamentoEnte("PEND-TIE-" + i); + v.setImportoTotale(10.0); + v.setImportoPagato(0.0); + v.setStatoVersamento("NON_ESEGUITO"); + v.setDataCreazione(stessaData); + v.setDataOraUltimoAggiornamento(stessaData); + v.setDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setDebitoreAnagrafica("Mario Rossi"); + v.setSrcDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setAnomalo(false); + v.setAck(false); + v.setTipo("DOVUTO"); + v.setDominio(dom); + v.setApplicazione(app); + v.setTipoVersamento(tv); + v.setTipoVersamentoDominio(tvd); + versamentoRepository.save(v); + } + + // Pagina 1: a parita' di dataCreazione, id piu' alto (ultimo inserito) per primo. + MvcResult r1 = mvc.perform(get("/pendenze?cursor=&limit=2&idPendenza=PEND-TIE") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(2))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-TIE-3"))) + .andExpect(jsonPath("$.results[1].idPendenza", is("PEND-TIE-2"))) + .andExpect(jsonPath("$.nextCursor", notNullValue())) + .andReturn(); + String cursor = extractStringField(r1.getResponse().getContentAsString(), "nextCursor"); + + // Pagina 2: solo la riga rimanente, nessun duplicato ne' perdita, ultima pagina. + mvc.perform(get("/pendenze?cursor=" + cursor + "&limit=2&idPendenza=PEND-TIE") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(1))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-TIE-1"))) + .andExpect(jsonPath("$.nextCursor").doesNotExist()); + } + + // ---- Issue #66: i nuovi filtri funzionano anche in modalita' cursor ---- + + /** Filtro diretto su colonna (§A), rappresentativo: stato + cursor su piu' pagine. */ + @Test + void cursorModeConFiltroStato() throws Exception { + // PEND-2/4/6 -> PAGATA, PEND-1/3/5/7 restano NON_PAGATA (NON_ESEGUITO, nessuna dataScadenza). + for (String id : new String[] { "PEND-2", "PEND-4", "PEND-6" }) { + Versamento v = versamentoRepository.findDetail(APP_COD, id).orElseThrow(); + v.setStatoVersamento("ESEGUITO"); + versamentoRepository.save(v); + } + + MvcResult r1 = mvc.perform(get("/pendenze?cursor=&limit=2&stato=NON_PAGATA") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(2))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-1"))) + .andExpect(jsonPath("$.results[1].idPendenza", is("PEND-3"))) + .andExpect(jsonPath("$.nextCursor", notNullValue())) + .andReturn(); + String cursor = extractStringField(r1.getResponse().getContentAsString(), "nextCursor"); + + mvc.perform(get("/pendenze?cursor=" + cursor + "&limit=2&stato=NON_PAGATA") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(2))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-5"))) + .andExpect(jsonPath("$.results[1].idPendenza", is("PEND-7"))) + .andExpect(jsonPath("$.nextCursor").doesNotExist()); + } + + /** + * Filtro diretto sulla STESSA colonna dell'ordinamento cursor (§A): l'interazione + * piu' delicata, dataDa/dataA e keyset condividono data_creazione. + */ + @Test + void cursorModeConFiltroDataRange() throws Exception { + Dominio dom = dominioRepository.findByCodDominio("77777777777").orElseThrow(); + Applicazione app = applicazioneRepository.findByCodApplicazione(APP_COD).orElseThrow(); + TipoVersamento tv = tipoVersamentoRepository.findByCodTipoVersamento("TARI").orElseThrow(); + TipoVersamentoDominio tvd = tipoVersamentoDominioRepository + .findByDominio_IdAndTipoVersamento_CodTipoVersamento(dom.getId(), "TARI").orElseThrow(); + + OffsetDateTime now = OffsetDateTime.now().truncatedTo(ChronoUnit.SECONDS); + int[] giorniFa = { 1, 2, 3, 10 }; + for (int i = 0; i < giorniFa.length; i++) { + Versamento v = new Versamento(); + v.setCodVersamentoEnte("PEND-RANGE-" + (i + 1)); + v.setImportoTotale(10.0); + v.setImportoPagato(0.0); + v.setStatoVersamento("NON_ESEGUITO"); + v.setDataCreazione(now.minusDays(giorniFa[i])); + v.setDataOraUltimoAggiornamento(now); + v.setDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setDebitoreAnagrafica("Mario Rossi"); + v.setSrcDebitoreIdentificativo("RSSMRA80A01H501U"); + v.setAnomalo(false); + v.setAck(false); + v.setTipo("DOVUTO"); + v.setDominio(dom); + v.setApplicazione(app); + v.setTipoVersamento(tv); + v.setTipoVersamentoDominio(tvd); + versamentoRepository.save(v); + } + + java.time.LocalDate oggi = java.time.LocalDate.now(); + String query = "/pendenze?cursor=&limit=2&idPendenza=PEND-RANGE" + + "&dataDa=" + oggi.minusDays(5) + "&dataA=" + oggi; + + // PEND-RANGE-4 (10gg fa) e' fuori range: solo 3 righe rientrano. + MvcResult r1 = mvc.perform(get(query).with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(2))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-RANGE-1"))) + .andExpect(jsonPath("$.results[1].idPendenza", is("PEND-RANGE-2"))) + .andExpect(jsonPath("$.nextCursor", notNullValue())) + .andReturn(); + String cursor = extractStringField(r1.getResponse().getContentAsString(), "nextCursor"); + + mvc.perform(get(query.replace("cursor=", "cursor=" + cursor)).with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(1))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-RANGE-3"))) + .andExpect(jsonPath("$.nextCursor").doesNotExist()); + } + + /** Filtro con join (§B), rappresentativo: idA2A + cursor su piu' pagine. */ + @Test + void cursorModeConFiltroIdA2A() throws Exception { + Applicazione appH = new Applicazione(); + appH.setCodApplicazione("APP-H"); + applicazioneRepository.save(appH); + + for (String id : new String[] { "PEND-2", "PEND-5" }) { + Versamento v = versamentoRepository.findDetail(APP_COD, id).orElseThrow(); + v.setApplicazione(appH); + versamentoRepository.save(v); + } + + MvcResult r1 = mvc.perform(get("/pendenze?cursor=&limit=1&idA2A=APP-H") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(1))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-2"))) + .andExpect(jsonPath("$.nextCursor", notNullValue())) + .andReturn(); + String cursor = extractStringField(r1.getResponse().getContentAsString(), "nextCursor"); + + mvc.perform(get("/pendenze?cursor=" + cursor + "&limit=1&idA2A=APP-H") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(1))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-5"))) + .andExpect(jsonPath("$.nextCursor").doesNotExist()); + } + + /** Filtro con join e semantica OR (§B): idTipoPendenza + cursor su piu' pagine. */ + @Test + void cursorModeConFiltroIdTipoPendenza() throws Exception { + TipoVersamento imu = new TipoVersamento(); + imu.setCodTipoVersamento("IMU"); + imu.setDescrizione("IMU"); + tipoVersamentoRepository.save(imu); + + for (String id : new String[] { "PEND-3", "PEND-6" }) { + Versamento v = versamentoRepository.findDetail(APP_COD, id).orElseThrow(); + v.setTipoVersamento(imu); + versamentoRepository.save(v); + } + + MvcResult r1 = mvc.perform(get("/pendenze?cursor=&limit=1&idTipoPendenza=IMU") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(1))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-3"))) + .andExpect(jsonPath("$.nextCursor", notNullValue())) + .andReturn(); + String cursor = extractStringField(r1.getResponse().getContentAsString(), "nextCursor"); + + mvc.perform(get("/pendenze?cursor=" + cursor + "&limit=1&idTipoPendenza=IMU") + .with(httpBasic(PRINCIPAL, PASSWORD))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.results", hasSize(1))) + .andExpect(jsonPath("$.results[0].idPendenza", is("PEND-6"))) + .andExpect(jsonPath("$.nextCursor").doesNotExist()); + } + @Test void cursorPlusPageReturns400WithParlanteMessage() throws Exception { mvc.perform(get("/pendenze?cursor=&page=2") diff --git a/src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java b/src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java index 96f3146..8cc5e99 100644 --- a/src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java +++ b/src/test/java/it/govpay/console/pendenza/PendenzaMapperTest.java @@ -34,10 +34,13 @@ class MappingDiretti { "ESEGUITO_SENZA_RPT, PAGATA", "ESEGUITA_PARZIALE, PAGATA_PARZIALE", "ESEGUITO_PARZIALE, PAGATA_PARZIALE", + "PARZIALMENTE_ESEGUITO, PAGATA_PARZIALE", "INCASSATA, RICONCILIATA", "INCASSATO, RICONCILIATA", "ANNULLATA, ANNULLATA", "ANNULLATO, ANNULLATA", + "SCADUTA, SCADUTA", + "SCADUTO, SCADUTA", "ANOMALA, ANOMALA", "ANOMALO, ANOMALA" }) @@ -89,5 +92,13 @@ void altriStatiIgnoranoScadenza() { OffsetDateTime scadenzaPassata = OffsetDateTime.ofInstant(NOW, ZoneOffset.UTC).minusDays(1); assertEquals(StatoPendenza.PAGATA, mapper.mapStato("ESEGUITO", scadenzaPassata)); } + + @Test + @DisplayName("SCADUTA letterale resta SCADUTA anche con dataScadenza futura o assente") + void scadutaLetteraleIndipendenteDaScadenza() { + OffsetDateTime scadenzaFutura = OffsetDateTime.ofInstant(NOW, ZoneOffset.UTC).plusDays(1); + assertEquals(StatoPendenza.SCADUTA, mapper.mapStato("SCADUTA", scadenzaFutura)); + assertEquals(StatoPendenza.SCADUTA, mapper.mapStato("SCADUTO", null)); + } } }