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));
+ }
}
}