diff --git a/.github/workflows/deploy-image.yml b/.github/workflows/deploy-image.yml index 5815376..befa434 100644 --- a/.github/workflows/deploy-image.yml +++ b/.github/workflows/deploy-image.yml @@ -1,10 +1,10 @@ # name: Create and publish a Docker image -# Configures this workflow to run every time a change is pushed to the branch called `release`. +# Configures this workflow to run every time a change is pushed to a branch. on: push: - branches: ['main'] + branches: ['**'] # Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. env: @@ -25,6 +25,29 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Compute application version + id: version + run: | + branch_tag=$(echo "${GITHUB_REF_NAME}" | tr '/[:upper:]' '-[:lower:]') + if [ "${GITHUB_REF_NAME}" = "main" ]; then + app_version="main-${GITHUB_SHA::12}" + else + app_version="${branch_tag}-${GITHUB_SHA::12}" + fi + echo "branch_tag=${branch_tag}" >> "$GITHUB_OUTPUT" + echo "app_version=${app_version}" >> "$GITHUB_OUTPUT" + - name: Check whether branch tag conflicts with a Git tag + id: branch-tag-check + run: | + if [ "${GITHUB_REF_NAME}" = "main" ]; then + echo "publish_branch_tag=false" >> "$GITHUB_OUTPUT" + elif git rev-parse --verify --quiet "refs/tags/${GITHUB_REF_NAME}" >/dev/null; then + echo "publish_branch_tag=false" >> "$GITHUB_OUTPUT" + else + echo "publish_branch_tag=true" >> "$GITHUB_OUTPUT" + fi # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - name: Log in to the Container registry uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 @@ -38,6 +61,10 @@ jobs: uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=main,enable=${{ github.ref_name == 'main' }} + type=raw,value=${{ steps.version.outputs.app_version }} + type=raw,value=${{ steps.version.outputs.branch_tag }},enable=${{ steps.branch-tag-check.outputs.publish_branch_tag == 'true' }} # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. @@ -51,7 +78,7 @@ jobs: build-args: | UID:"${DOCKER_UID}" GID:"${DOCKER_GID}" - APP_VERSION:"1.43" + APP_VERSION:"${{ steps.version.outputs.app_version }}" tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/deploy-release.yml b/.github/workflows/deploy-release.yml index ac373f3..425ecdd 100644 --- a/.github/workflows/deploy-release.yml +++ b/.github/workflows/deploy-release.yml @@ -26,6 +26,10 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Compute application version + id: version + run: | + echo "app_version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. - name: Log in to the Container registry uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 @@ -39,6 +43,10 @@ jobs: uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ steps.version.outputs.app_version }} + type=raw,value=${{ github.ref_name }} + type=raw,value=latest # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. @@ -52,7 +60,9 @@ jobs: build-args: | UID:"${DOCKER_UID}" GID:"${DOCKER_GID}" - tags: ghcr.io/${{ github.repository_owner }}/${{ github.repository }}:${{ github.ref_name }} + APP_VERSION:"${{ steps.version.outputs.app_version }}" + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." - name: Generate artifact attestation diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 169f452..ab6c130 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -29,6 +29,13 @@ jobs: cache: maven - name: Build with Maven run: mvn -B clean install --file pom.xml + - name: Upload App Coverage Report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-app-report + path: app/target/site/jacoco + if-no-files-found: warn # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive #- name: Update dependency graph diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de2cd32 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +app/target/ +db/target/ +AGENTS.md +comparaison-api-v2-vs-v3 _1_.md +doc/target/ + diff --git a/README.md b/README.md index 093ad0a..5d77e2a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,22 @@ Exemple pour Visual Studio Code .vscode/launch.json : } ``` +### Configuration de l'API REMOcRA + +Le client REMOcRA est configuré en v3 only avec authentification Keycloak / OIDC. + +Variables d'environnement disponibles : + +```sh +API_HOST=http://localhost:8881 +API_BASE_PATH=/api +API_AUTH_TYPE=keycloak +API_KEYCLOAK_URL=https://keycloak.example +API_KEYCLOAK_REALM=remocra +API_KEYCLOAK_CLIENT_ID=sync-remocra +API_KEYCLOAK_CLIENT_SECRET=change-me +``` + ## Formatage de code (API) diff --git a/app/pom.xml b/app/pom.xml index e7808da..7a91fb5 100644 --- a/app/pom.xml +++ b/app/pom.xml @@ -128,6 +128,40 @@ + + maven-compiler-plugin + + false + + + org.immutables + value + ${immutables.version} + + + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + org.apache.maven.plugins maven-jar-plugin diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiEndpoints.java b/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiEndpoints.java new file mode 100644 index 0000000..acc3a51 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiEndpoints.java @@ -0,0 +1,101 @@ +package fr.eaudeparis.syncremocra.api; + +/** + * Centralise les chemins des endpoints REMOcRA utilisés par l'application. + * + *

Cette classe ne gère que les chemins relatifs. La construction de l'URL complète ({@code host + * + basePath + path}) reste du ressort de {@link fr.eaudeparis.syncremocra.util.RequestManager}. + */ +public class ApiEndpoints { + + private static final String DECI_ROOT = "/deci"; + private static final String PEI_ROOT = DECI_ROOT + "/pei"; + private static final String REFERENTIEL_ROOT = DECI_ROOT + "/referentiel"; + private static final String INDISPO_TEMPORAIRE_ROOT = DECI_ROOT + "/indispoTemporaire"; + private static final String VISITES_SEGMENT = "/visites"; + private static final String CARACTERISTIQUES_SEGMENT = "/caracteristiques"; + + /** @return Endpoint de récupération des diffs PEI */ + public String peiDiff() { + return PEI_ROOT + "/diff"; + } + + /** + * @param reference Référence ou numéro du PEI + * @return Endpoint du PEI ciblé + */ + public String pei(String reference) { + return PEI_ROOT + "/" + reference; + } + + /** + * @param reference Référence ou numéro du PEI + * @return Endpoint des caractéristiques du PEI ciblé + */ + public String peiCaracteristiques(String reference) { + return pei(reference) + CARACTERISTIQUES_SEGMENT; + } + + /** + * @param reference Référence ou numéro du PEI + * @return Endpoint de mise à jour des caractéristiques PIBI + */ + public String peiPibiCaracteristiques(String reference) { + return pei(reference) + "/pibi-caracteristiques"; + } + + /** + * @param reference Référence ou numéro du PEI + * @return Endpoint de mise à jour des caractéristiques PENA + */ + public String peiPenaCaracteristiques(String reference) { + return pei(reference) + "/pena-caracteristiques"; + } + + /** + * @param reference Référence ou numéro du PEI + * @return Endpoint de la collection de visites du PEI ciblé + */ + public String peiVisites(String reference) { + return pei(reference) + VISITES_SEGMENT; + } + + /** + * @param reference Référence ou numéro du PEI + * @param identifiantVisite Identifiant de la visite + * @return Endpoint d'une visite spécifique d'un PEI + */ + public String peiVisite(String reference, Object identifiantVisite) { + return peiVisites(reference) + "/" + identifiantVisite; + } + + /** @return Endpoint de la collection des indisponibilités temporaires */ + public String indispoTemporaire() { + return INDISPO_TEMPORAIRE_ROOT; + } + + /** + * @param identifiant Identifiant de l'indisponibilité temporaire + * @return Endpoint d'une indisponibilité temporaire spécifique + */ + public String indispoTemporaire(Object identifiant) { + return INDISPO_TEMPORAIRE_ROOT + "/" + identifiant; + } + + /** + * @param type Type de PEI en minuscule, par exemple {@code pibi} ou {@code pena} + * @param nature Nature du PEI + * @return Endpoint du référentiel des anomalies pour le type et la nature fournis + */ + public String referentielNaturesAnomalies(String type, String nature) { + return REFERENTIEL_ROOT + "/" + type + "/" + nature + "/naturesAnomalies"; + } + + /** + * @param type Type de PEI en minuscule, par exemple {@code pibi} ou {@code pena} + * @return Endpoint du référentiel des natures PEI pour le type fourni + */ + public String referentielNaturesPei(String type) { + return REFERENTIEL_ROOT + "/" + type + "/naturesPEI"; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiModule.java b/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiModule.java index 52e21ad..1c170c5 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiModule.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiModule.java @@ -11,10 +11,31 @@ public class ApiModule extends AbstractModule { public static ApiModule create(Config config) { ImmutableApiSettings.Builder builder = ImmutableApiSettings.builder(); - builder - .host(config.getString("host")) - .mail(config.getString("mail")) - .password(config.getString("password")); + builder.host(config.getString("host")); + if (config.hasPath("mail")) { + builder.mail(config.getString("mail")); + } + if (config.hasPath("password")) { + builder.password(config.getString("password")); + } + if (config.hasPath("base_path")) { + builder.basePath(config.getString("base_path")); + } + if (config.hasPath("auth_type")) { + builder.authType(config.getString("auth_type")); + } + if (config.hasPath("keycloak_url")) { + builder.keycloakUrl(config.getString("keycloak_url")); + } + if (config.hasPath("keycloak_realm")) { + builder.keycloakRealm(config.getString("keycloak_realm")); + } + if (config.hasPath("keycloak_client_id")) { + builder.keycloakClientId(config.getString("keycloak_client_id")); + } + if (config.hasPath("keycloak_client_secret")) { + builder.keycloakClientSecret(config.getString("keycloak_client_secret")); + } return new ApiModule(builder.build()); } @@ -25,5 +46,6 @@ public ApiModule(ImmutableApiSettings settings) { @Override protected void configure() { bind(ApiSettings.class).toInstance(apiSettings); + bind(ApiEndpoints.class).asEagerSingleton(); } } diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiSettings.java b/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiSettings.java index d4fe7ac..714b579 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiSettings.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/api/ApiSettings.java @@ -4,9 +4,54 @@ @Value.Immutable public interface ApiSettings { + /** @return URL racine de l'instance REMOcRA ou du reverse proxy exposant l'API */ String host(); - String password(); + /** @return Préfixe optionnel ajouté devant les endpoints, par exemple {@code /api} */ + @Value.Default + default String basePath() { + return ""; + } - String mail(); + /** @return Type d'authentification API supporté par le client v3, soit {@code keycloak} */ + @Value.Default + default String authType() { + return "keycloak"; + } + + /** @return Mot de passe historique conservé pour compatibilité de configuration locale */ + @Value.Default + default String password() { + return ""; + } + + /** @return Adresse e-mail historique conservée pour compatibilité de configuration locale */ + @Value.Default + default String mail() { + return ""; + } + + /** @return URL de base de Keycloak en mode OIDC */ + @Value.Default + default String keycloakUrl() { + return ""; + } + + /** @return Realm Keycloak utilisé pour récupérer le jeton OIDC */ + @Value.Default + default String keycloakRealm() { + return ""; + } + + /** @return Client ID Keycloak utilisé pour le flux {@code client_credentials} */ + @Value.Default + default String keycloakClientId() { + return ""; + } + + /** @return Secret du client Keycloak utilisé pour le flux {@code client_credentials} */ + @Value.Default + default String keycloakClientSecret() { + return ""; + } } diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/job/NotificationJob.java b/app/src/main/java/fr/eaudeparis/syncremocra/job/NotificationJob.java index ca683b0..b02696b 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/job/NotificationJob.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/job/NotificationJob.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; import fr.eaudeparis.syncremocra.db.model.tables.pojos.Message; import fr.eaudeparis.syncremocra.db.model.tables.pojos.TracabilitePei; import fr.eaudeparis.syncremocra.db.model.tables.pojos.VueErreurToNotify; @@ -44,6 +45,8 @@ public class NotificationJob implements Job { @Inject RequestManager requestManager; + @Inject ApiEndpoints apiEndpoints; + @Inject public NotificationJob(MailUtil mailer, ErreurRepository erreurRepository, DSLContext context) { this.mailer = mailer; @@ -192,7 +195,7 @@ private void checkDisponibiliteCoherence() { try { String indispoEnCours = - this.requestManager.sendGetRequest("/api/deci/pei/" + traca.getReference()); + this.requestManager.sendGetRequest(apiEndpoints.pei(traca.getReference())); TypeReference> typeRef = new TypeReference>() {}; Map dataHydrant = mapper.readValue(indispoEnCours, typeRef); diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/erreur/ErreurRepository.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/erreur/ErreurRepository.java index 4d5c548..2b27191 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/repository/erreur/ErreurRepository.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/erreur/ErreurRepository.java @@ -15,9 +15,13 @@ import javax.inject.Inject; import org.jooq.DSLContext; import org.jooq.XML; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class ErreurRepository { + private static final Logger logger = LoggerFactory.getLogger(ErreurRepository.class); + private final DSLContext context; @Inject @@ -41,6 +45,15 @@ public void addError(String codeErreur, String message, Long idMessage) { .where(TYPE_ERREUR.CODE.equal(codeErreur)) .fetchOneInto(TypeErreurModel.class); + if (typeErreur == null) { + logger.warn( + "Type d'erreur inconnu, aucune notification persistée. codeErreur={}, idMessage={}, message={}", + codeErreur, + idMessage, + message); + return; + } + /* Si c'est une erreur de connexion (base Oracle/API), on ne rajoute pas l'erreur si il en existe déjà une identique qui n'a pas encore été notifiée */ diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/IndispoTemporaireMapper.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/IndispoTemporaireMapper.java new file mode 100644 index 0000000..ee2f10c --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/IndispoTemporaireMapper.java @@ -0,0 +1,142 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import fr.eaudeparis.syncremocra.util.JSONUtil; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Mappe les indisponibilités temporaires REMOcRA v3 vers SyncRemocra. */ +public final class IndispoTemporaireMapper { + + private static final String ORGANISME_API = "EAU_DE_PARIS"; + private static final String DEFAULT_MOTIF = "Mise en indisponibilité Eau de Paris"; + + private IndispoTemporaireMapper() {} + + /** + * Construit les paramètres de recherche des indisponibilités temporaires. + * + * @return paramètres de requête pour l'API REMOcRA + */ + public static Map buildSearchParams() { + Map params = new HashMap(); + params.put("organismeApi", ORGANISME_API); + return params; + } + + /** + * Recherche l'indisponibilité temporaire active liée à un PEI. + * + * @param indispos liste des indisponibilités retournées par REMOcRA + * @param reference référence du PEI + * @return indisponibilité active correspondante, ou {@code null} si absente + */ + public static Map findActiveIndispo( + List> indispos, String reference) { + for (Map indispo : indispos) { + if (containsReference(indispo, reference) && isActive(indispo)) { + return indispo; + } + } + return null; + } + + /** + * Construit le payload v3 de création d'une indisponibilité temporaire. + * + * @param mapper mapper Jackson + * @param reference référence du PEI + * @param dateDebut date de début locale + * @return payload prêt à être sérialisé + */ + public static ObjectNode buildCreatePayload( + ObjectMapper mapper, String reference, LocalDateTime dateDebut) { + ObjectNode data = mapper.createObjectNode(); + data.put("motif", DEFAULT_MOTIF); + data.put("dateDebut", formatApiDate(dateDebut)); + data.put("mailAvantIndisponibilite", true); + data.put("mailApresIndisponibilite", true); + data.put("basculeAutoDisponible", true); + data.put("basculeAutoIndisponible", true); + data.set("listeNumeroPei", createPeiList(mapper, reference)); + return data; + } + + /** + * Construit le payload v3 de clôture d'une indisponibilité temporaire existante. + * + * @param mapper mapper Jackson + * @param reference référence du PEI + * @param indispoEnCours indisponibilité en cours + * @param dateFin date de fin locale + * @return payload prêt à être sérialisé + */ + public static ObjectNode buildUpdatePayload( + ObjectMapper mapper, + String reference, + Map indispoEnCours, + LocalDateTime dateFin) { + ObjectNode data = buildCreatePayload(mapper, reference, dateFin); + data.put("dateDebut", getDateDebut(indispoEnCours)); + data.put("dateFin", formatApiDate(dateFin)); + return data; + } + + /** + * @param indispo indisponibilité retournée par REMOcRA + * @return identifiant d'indisponibilité, ou {@code null} si absent + */ + public static Object getIndispoId(Map indispo) { + return indispo.get("indisponibiliteTemporaireId"); + } + + /** + * @param indispo indisponibilité retournée par REMOcRA + * @return date de début au format attendu par l'API + */ + public static String getDateDebut(Map indispo) { + return JSONUtil.getString(indispo, "indisponibiliteTemporaireDateDebut"); + } + + private static boolean containsReference(Map indispo, String reference) { + Object listeNumeroPei = indispo.get("listeNumeroPei"); + if (listeNumeroPei instanceof List) { + for (Object pei : (List) listeNumeroPei) { + if (reference.equals(String.valueOf(pei))) { + return true; + } + } + return false; + } + if (listeNumeroPei != null) { + String[] references = listeNumeroPei.toString().split("\\s*,\\s*"); + for (String currentReference : references) { + if (reference.equals(currentReference)) { + return true; + } + } + } + + return false; + } + + private static boolean isActive(Map indispo) { + return JSONUtil.getString(indispo, "indisponibiliteTemporaireDateFin") == null; + } + + private static ArrayNode createPeiList(ObjectMapper mapper, String reference) { + ArrayNode peiList = mapper.createArrayNode(); + peiList.add(reference); + return peiList; + } + + private static String formatApiDate(LocalDateTime date) { + return date.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME); + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/MessageRepository.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/MessageRepository.java index c7e18dc..cf57ad6 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/MessageRepository.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/MessageRepository.java @@ -15,6 +15,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.inject.persist.Transactional; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; import fr.eaudeparis.syncremocra.db.model.tables.pojos.ReferentielMarquesModeles; import fr.eaudeparis.syncremocra.db.model.tables.pojos.TracabilitePei; import fr.eaudeparis.syncremocra.db.model.tables.pojos.TypeErreur; @@ -27,10 +28,10 @@ import fr.eaudeparis.syncremocra.util.APIAuthentException; import fr.eaudeparis.syncremocra.util.APIConnectionException; import fr.eaudeparis.syncremocra.util.InternalException; -import fr.eaudeparis.syncremocra.util.JSONUtil; import fr.eaudeparis.syncremocra.util.RequestException; import fr.eaudeparis.syncremocra.util.RequestManager; import java.net.HttpURLConnection; +import java.text.Normalizer; import java.text.SimpleDateFormat; import java.time.Instant; import java.time.ZoneId; @@ -38,7 +39,6 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.Date; -import java.util.HashMap; import java.util.List; import java.util.Map; import javax.inject.Inject; @@ -62,6 +62,8 @@ public class MessageRepository { @Inject RequestManager requestManager; + @Inject ApiEndpoints apiEndpoints; + @Inject PeiRepository peiRepository; @Inject ParametresRepository parametresRepository; @@ -125,22 +127,30 @@ public void traiterMessages() { } else if ("CARACTERISTIQUES".equalsIgnoreCase(message.getType())) { logger.debug("Traitement CARACTERISTIQUES " + message.getId()); - data = this.traiterMessageCaracteristiques(message, reference); - path = "/api/deci/pei/" + reference + "/caracteristiques"; + PeiCaracteristiquesUpdate update = + this.traiterMessageCaracteristiques(message, reference); + data = update.getPayload(); + path = update.getPath(); methode = "PUT"; } else if ("SPECIFIQUE".equalsIgnoreCase(message.getType())) { logger.debug("Traitement SPECIFIQUE " + message.getId()); data = this.traiterMessageSpecifique(message, reference); - path = "/api/deci/pei/" + reference + "/visites"; + path = apiEndpoints.peiVisites(reference); methode = "POST"; } else if ("MANUELLE".equalsIgnoreCase(message.getType())) { logger.debug("Traitement MANUELLE " + message.getId()); data = this.traiterMessageManuelle(message, reference); - path = "/api/deci/pei/" + reference + "/visites"; + path = apiEndpoints.peiVisites(reference); methode = "POST"; } - String json = mapper.writeValueAsString(data); + ObjectNode trackedData = + ("SPECIFIQUE".equalsIgnoreCase(message.getType()) + || "MANUELLE".equalsIgnoreCase(message.getType())) + ? VisitPayloadTracker.toTrackedPayload(data) + : data; + String json = mapper.writeValueAsString(trackedData); + String requestJson = mapper.writeValueAsString(data); context .update(MESSAGE) @@ -153,7 +163,7 @@ public void traiterMessages() { * a déjà été traitée par RequestManager (éventuellement par une ResponseException) */ if (methode != null && path != null && json != null) { - Integer codeRetour = this.requestManager.sendRequest(methode, path, json); + Integer codeRetour = this.requestManager.sendRequest(methode, path, requestJson); if (codeRetour != null && (codeRetour == HttpURLConnection.HTTP_CREATED @@ -320,42 +330,63 @@ private ObjectNode traiterMessageVisite(MessageModel message, String reference) .selectFrom(TRACABILITE_PEI) .where(TRACABILITE_PEI.ID.eq(message.getId_traca_pei())) .fetchOneInto(TracabilitePei.class); + List currentIndispoMotifs = getCurrentIndispoMotifs(traca); + boolean shouldCreateTemporaryUnavailability = + shouldCreateTemporaryUnavailability(currentIndispoMotifs); // On récupère l'éventuelle indispo temporaire en cours - Map params = new HashMap(); - params.put("organismeApi", "EAU_DE_PARIS"); - params.put("numeroHydrant", reference); - params.put("statut", "EN_COURS"); String indispoEnCours = - this.requestManager.sendGetRequest("/api/deci/indispoTemporaire", params); + this.requestManager.sendGetRequest( + apiEndpoints.indispoTemporaire(), IndispoTemporaireMapper.buildSearchParams()); + TypeReference>> typeRef = + new TypeReference>>() {}; + ArrayList> dataIndispoEnCours = mapper.readValue(indispoEnCours, typeRef); + Map indispoActive = + IndispoTemporaireMapper.findActiveIndispo(dataIndispoEnCours, reference); + List trackedIndispoMotifs = getTrackedIndispoMotifs(reference); // Si Indisponible, on créé une indispo temporaire s'il n'en existe pas déjà une // sur ce PEI if ("Indisponible".equalsIgnoreCase(traca.getEtat())) { - if (indispoEnCours.length() <= 2) { // Null ou tableau vide + if (!shouldCreateTemporaryUnavailability) { + logger.info( + "PEI " + + reference + + " indisponible - aucun motif ne justifie une indisponibilité temporaire"); + this.remonteeMotifsIndispo(message, reference); + if (indispoActive != null) { + logger.info( + "PEI " + + reference + + " indisponible - clôture de l'indispo temporaire existante devenue" + + " invalide"); + indispoTemp.put("methode", "PUT"); + indispoTemp.put( + "path", + apiEndpoints.indispoTemporaire(IndispoTemporaireMapper.getIndispoId(indispoActive))); + indispoTemp.set( + "data", + IndispoTemporaireMapper.buildUpdatePayload( + mapper, reference, indispoActive, traca.getDateMajE())); + return indispoTemp; + } + context + .update(MESSAGE) + .set(MESSAGE.STATUT, "TRAITE") + .where(MESSAGE.ID.eq(message.getId())) + .execute(); + } else if (indispoActive == null) { logger.info("PEI " + reference + " indisponible - Création d'une indispo temporaire EDP"); indispoTemp.put("methode", "POST"); - indispoTemp.put("path", "/api/deci/indispoTemporaire"); - ObjectNode data = mapper.createObjectNode(); - ArrayNode hydrants = mapper.createArrayNode(); - hydrants.add(reference); - - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + indispoTemp.put("path", apiEndpoints.indispoTemporaire()); try { - data.put("date_debut", traca.getDateMajE().format(formatter)); + indispoTemp.set( + "data", + IndispoTemporaireMapper.buildCreatePayload(mapper, reference, traca.getDateMajE())); } catch (Exception e) { logger.error("Le champ date_maj_e présente une anomalie (traca " + traca.getId() + ")"); throw e; } - data.put("motif", "Mise en indisponibilité Eau de Paris"); - data.put("statut", "PLANIFIE"); - data.put("bascule_auto_dispo", true); - data.put("bascule_auto_indispo", true); - data.put("mel_avant_dispo", true); - data.put("mel_avant_indispo", true); - data.set("hydrants", hydrants); - - indispoTemp.set("data", data); this.remonteeMotifsIndispo(message, reference); return indispoTemp; @@ -378,12 +409,13 @@ private ObjectNode traiterMessageVisite(MessageModel message, String reference) .equalsIgnoreCase( traca .getEtat())) { // Si disponible, on met fin à l'indispo temporaire active sur ce pei - if (indispoEnCours.length() <= 2) { - logger.info( - "PEI " - + reference - + " disponible - Aucune indispo temporaire EDP active, fin du traitement du" - + " message"); + if (indispoActive == null) { + logger.info("PEI " + reference + " disponible - Aucune indispo temporaire EDP active"); + if (!trackedIndispoMotifs.isEmpty()) { + logger.info( + "PEI " + reference + " disponible - remontée de la levée des motifs EDP mémorisés"); + this.remonteeMotifsIndispo(message, reference); + } context .update(MESSAGE) .set(MESSAGE.STATUT, "TRAITE") @@ -391,35 +423,46 @@ private ObjectNode traiterMessageVisite(MessageModel message, String reference) .execute(); } else { logger.info("PEI " + reference + " disponible - Fin de l'indispo temporaire EDP active"); - TypeReference>> typeRef = - new TypeReference>>() {}; - ArrayList> dataIndispoEnCours = - mapper.readValue(indispoEnCours, typeRef); - Long idIndispoTemp = JSONUtil.getLong(dataIndispoEnCours.get(0), "identifiant"); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - indispoTemp.put("methode", "PUT"); - indispoTemp.put("path", "/api/deci/indispoTemporaire/" + idIndispoTemp); - - ObjectNode data = mapper.createObjectNode(); - - data.put("date_debut", JSONUtil.getString(dataIndispoEnCours.get(0), "date_debut")); - data.put("date_fin", traca.getDateMajE().format(formatter)); - data.put("motif", "Mise en indisponibilité Eau de Paris"); - data.put("statut", "EN_COURS"); - data.put("bascule_auto_dispo", true); - data.put("bascule_auto_indispo", true); - data.put("mel_avant_dispo", true); - data.put("mel_avant_indispo", true); - - indispoTemp.set("data", data); - this.remonteeMotifsIndispo(message, reference); + indispoTemp.put( + "path", + apiEndpoints.indispoTemporaire(IndispoTemporaireMapper.getIndispoId(indispoActive))); + + indispoTemp.set( + "data", + IndispoTemporaireMapper.buildUpdatePayload( + mapper, reference, indispoActive, traca.getDateMajE())); + if (!trackedIndispoMotifs.isEmpty()) { + this.remonteeMotifsIndispo(message, reference); + } return indispoTemp; } } return null; } + private List getCurrentIndispoMotifs(TracabilitePei traca) { + return context + .select(DSL.upper(TRACABILITE_INDISPO.MOTIF_INDISPO)) + .from(TRACABILITE_INDISPO) + .where(TRACABILITE_INDISPO.ID_TRACA_PEI.eq(traca.getId().longValue())) + .fetchInto(String.class); + } + + /** + * Récupère les motifs EDP qui ont été précédemment remontés à REMOcRA pour un PEI. + * + * @param reference référence du PEI + * @return motifs d'indisponibilité mémorisés localement + */ + private List getTrackedIndispoMotifs(String reference) { + return context + .select(MOTIF_INDISPO_ACTIF.MOTIF) + .from(MOTIF_INDISPO_ACTIF) + .where(MOTIF_INDISPO_ACTIF.REFERENCE.eq(reference)) + .fetchInto(String.class); + } + /** * En cas de création d'une indispo temporaire, remonte les éventuels motifs d'indispo renseignées * dans Remocra via une visite Non Programmee @@ -474,24 +517,20 @@ private ObjectNode remonteeMotifsIndispo(MessageModel message, String reference) .where(MOTIF_INDISPO_ACTIF.REFERENCE.eq(traca.getReference())) .fetchInto(String.class); - // "ARRET EAU" est une anomalie qui ne donne pas lieu à une visite dans REMOcRA - // si elle est - // la seule anomalie - if (motifIndispo.size() == 1 && motifIndispo.contains("ARRET EAU")) { - logger.info( - "Mise en disponible avec pour seul motif actif ARRET EAU : pas de création de" - + " visite"); - context - .deleteFrom(MOTIF_INDISPO_ACTIF) - .where(MOTIF_INDISPO_ACTIF.REFERENCE.eq(traca.getReference())) - .execute(); + if (!shouldCreateIndispoClosureVisit(motifIndispo)) { + if (motifIndispo.size() == 1 && "ARRET EAU".equalsIgnoreCase(motifIndispo.get(0))) { + logger.info( + "Mise en disponible avec pour seul motif actif ARRET EAU : pas de création de" + + " visite"); + context + .deleteFrom(MOTIF_INDISPO_ACTIF) + .where(MOTIF_INDISPO_ACTIF.REFERENCE.eq(traca.getReference())) + .execute(); + } return null; - } - - // les indisposActive ne concerne pas qu'arret eau - else { + } else { logger.info( - "Mise en disponible avec D'AUTRE motif actif que ARRET EAU : : Création de visite"); + "Mise en disponible avec des motifs EDP actifs : création de visite de levée"); /** * On remplit le tableau anomaliesAControler à pour confirmer dans la visite que les * anomalies ont été controllées mais pas constantées @@ -632,13 +671,13 @@ private ObjectNode remonteeMotifsIndispo(MessageModel message, String reference) } } - visite.put("contexte", "NP"); + visite.put("typeVisite", "NP"); visite.put("date", formatter.format(dateChangement)); visite.set("anomaliesControlees", arrayAnomaliesControlees); visite.set("anomaliesConstatees", arrayAnomaliesConstatees); visite.put("agent1", "Eau de Paris"); - String json = mapper.writeValueAsString(visite); + String json = mapper.writeValueAsString(VisitPayloadTracker.toTrackedPayload(visite)); logger.info("JSON remontée des motifs d'indispo : " + json); context .update(MESSAGE) @@ -647,7 +686,7 @@ private ObjectNode remonteeMotifsIndispo(MessageModel message, String reference) .execute(); Integer codeRetour = - this.requestManager.sendRequest("POST", "/api/deci/pei/" + reference + "/visites", json); + this.requestManager.sendRequest("POST", apiEndpoints.peiVisites(reference), json); logger.info("Disponibilité: création d'une visite (retour : " + codeRetour + ")"); // On modifie les motifs d'indispo actifs APRES la création de visite et si elle @@ -702,17 +741,28 @@ private ObjectNode remonteeMotifsIndispo(MessageModel message, String reference) * @throws APIConnectionException Impossible de contacter l'API * @throws APIAuthentException Impossible de s'authentifier à l'API */ - private ObjectNode traiterMessageCaracteristiques(MessageModel message, String reference) + private PeiCaracteristiquesUpdate traiterMessageCaracteristiques( + MessageModel message, String reference) throws APIConnectionException, APIAuthentException, RequestException, InternalException { try { + String dataPeiSpecifique = this.requestManager.sendGetRequest(apiEndpoints.pei(reference)); String dataPei = - this.requestManager.sendGetRequest("/api/deci/pei/" + reference + "/caracteristiques"); + this.requestManager.sendGetRequest(apiEndpoints.peiCaracteristiques(reference)); + String dataNaturesPibi = + this.requestManager.sendGetRequest( + apiEndpoints.referentielNaturesPei(PeiType.PIBI.getApiValue())); + String dataNaturesPena = + this.requestManager.sendGetRequest( + apiEndpoints.referentielNaturesPei(PeiType.PENA.getApiValue())); ObjectMapper mapper = new ObjectMapper(); TypeReference> typeRef = new TypeReference>() {}; + TypeReference>> listTypeRef = + new TypeReference>>() {}; + Map dataPeiDetail = mapper.readValue(dataPeiSpecifique, typeRef); Map data = mapper.readValue(dataPei, typeRef); - - ObjectNode caracteristiques = mapper.createObjectNode(); + List> pibiNatures = mapper.readValue(dataNaturesPibi, listTypeRef); + List> penaNatures = mapper.readValue(dataNaturesPena, listTypeRef); TracabilitePei traca = context @@ -762,34 +812,26 @@ private ObjectNode traiterMessageCaracteristiques(MessageModel message, String r } } - // Données EDP - caracteristiques.put("codeMarque", codeMarque); - caracteristiques.put("codeModele", codeModele); - caracteristiques.put( - "diametreCanalisation", - traca.getDiametreCanalisation() != null - ? Integer.valueOf(traca.getDiametreCanalisation()) - : null); - caracteristiques.put("codeDiametre", codeDiametre); - - // Consolidation données remocra - caracteristiques.put("capaciteIllimitee", JSONUtil.getBoolean(data, "illimite")); - caracteristiques.put("ressourceIncertaine", JSONUtil.getBoolean(data, "incertaine")); - caracteristiques.put("codeNatureReseau", JSONUtil.getString(data, "natureReseau")); - caracteristiques.put( - "codeNatureCanalisation", JSONUtil.getString(data, "natureCanalisation")); - caracteristiques.put("reseauSurpresse", JSONUtil.getBoolean(data, "reseauSurpresse")); - caracteristiques.put("reseauAdditive", JSONUtil.getBoolean(data, "reseauAdditive")); - caracteristiques.put("capacite", JSONUtil.getString(data, "capacite")); - caracteristiques.put("debitAppoint", JSONUtil.getDouble(data, "debitAppoint")); - caracteristiques.put("codeMateriau", JSONUtil.getString(data, "codeMateriau")); - caracteristiques.put("equipeHBE", JSONUtil.getBoolean(data, "equipeHBE")); - caracteristiques.put("peiJumele", JSONUtil.getString(data, "jumelage")); - caracteristiques.put("inviolabilite", JSONUtil.getBoolean(data, "inviolabilite")); - caracteristiques.put("renversable", JSONUtil.getBoolean(data, "renversable")); - caracteristiques.put("anneeFabrication", JSONUtil.getInteger(data, "anneeFabrication")); - - return caracteristiques; + PeiType peiType = PeiTypeResolver.resolve(dataPeiDetail, pibiNatures, penaNatures); + if (peiType == null) { + TypeErreur typeErreur = + context + .selectFrom(TYPE_ERREUR) + .where(TYPE_ERREUR.CODE.equal("I1004")) + .fetchOneInto(TypeErreur.class); + throw new InternalException(typeErreur.getCode(), typeErreur.getMessageErreur()); + } + + return PeiCaracteristiquesUpdateFactory.create( + mapper, + apiEndpoints, + peiType, + reference, + traca, + data, + codeDiametre, + codeMarque, + codeModele); } catch (JsonProcessingException e) { e.printStackTrace(); @@ -805,14 +847,11 @@ private ObjectNode traiterMessageCaracteristiques(MessageModel message, String r * @return Un json contenant les données de la visite à envoyer à Remocra */ private ObjectNode traiterMessageSpecifique(MessageModel message, String reference) - throws APIConnectionException, RequestException, JsonProcessingException, - APIAuthentException { + throws APIConnectionException, RequestException, JsonProcessingException, APIAuthentException, + InternalException { ObjectMapper mapper = new ObjectMapper(); ObjectNode visite = mapper.createObjectNode(); - ArrayNode arrayAnomaliesControlees = mapper.createArrayNode(); - ArrayNode arrayAnomaliesConstatees = mapper.createArrayNode(); - TracabilitePei traca = context .selectFrom(TRACABILITE_PEI) @@ -820,18 +859,26 @@ private ObjectNode traiterMessageSpecifique(MessageModel message, String referen .fetchOneInto(TracabilitePei.class); String typeDerniereVisite = traca.getTypeDerniereVisite().toUpperCase(); + String typeVisite = VisitTypeMapper.toRemocraType(typeDerniereVisite); - if (typeDerniereVisite.startsWith("PICF")) { - visite.put("contexte", "CTRL"); - } else if (typeDerniereVisite.startsWith("PIQP")) { - visite.put("contexte", "CTRL"); - visite = this.recuperationsValeursDebitPression(visite, traca); + if ("CTP".equals(typeVisite)) { + visite.put("typeVisite", "CTP"); + if (typeDerniereVisite.startsWith("PIQP")) { + visite = this.recuperationsValeursDebitPression(visite, traca); + } } else if ((typeDerniereVisite.startsWith("NPQP"))) { // Dans le cas d'une NPQP, le comportement attendu est identique à celui d'une // intervention manuelle (NP avec anomalies sans débit/pression) return this.traiterMessageManuelle(message, reference); - } else if ((typeDerniereVisite.startsWith("NP"))) { - visite.put("contexte", "NP"); + } else if ("NP".equals(typeVisite)) { + visite.put("typeVisite", "NP"); + } else { + TypeErreur typeErreur = + context + .selectFrom(TYPE_ERREUR) + .where(TYPE_ERREUR.CODE.equal("2001")) + .fetchOneInto(TypeErreur.class); + throw new InternalException(typeErreur.getCode(), typeErreur.getMessageErreur()); } DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); @@ -861,14 +908,11 @@ private ObjectNode traiterMessageSpecifique(MessageModel message, String referen * @return Un json contenant les données de la visite à envoyer à Remocra */ private ObjectNode traiterMessageManuelle(MessageModel message, String reference) - throws APIConnectionException, RequestException, JsonProcessingException, - APIAuthentException { + throws APIConnectionException, RequestException, JsonProcessingException, APIAuthentException, + InternalException { ObjectMapper mapper = new ObjectMapper(); ObjectNode visite = mapper.createObjectNode(); - ArrayNode arrayAnomaliesControlees = mapper.createArrayNode(); - ArrayNode arrayAnomaliesConstatees = mapper.createArrayNode(); - TracabilitePei traca = context .selectFrom(TRACABILITE_PEI) @@ -883,8 +927,9 @@ private ObjectNode traiterMessageManuelle(MessageModel message, String reference } else { visite.put("date", traca.getDateEssai().format(formatter)); } - visite.put("contexte", "NP"); + visite.put("typeVisite", "NP"); visite.put("agent1", "Eau de Paris"); + appendHydraulicMeasurementsIfPresent(visite, traca); visite = this.recuperationAnomalies(visite, traca); return visite; } @@ -897,18 +942,106 @@ private ObjectNode traiterMessageManuelle(MessageModel message, String reference * @return Les données de la visite */ private ObjectNode recuperationsValeursDebitPression(ObjectNode v, TracabilitePei traca) { - v.put( + return appendHydraulicMeasurementsIfPresent(v, traca); + } + + /** + * Ajoute les mesures hydrauliques à la visite lorsqu'elles sont disponibles dans la traca. + * + * @param visitPayload payload de visite à enrichir + * @param traca ligne de tracabilité source + * @return payload enrichi, inchangé si aucune mesure n'est disponible + */ + static ObjectNode appendHydraulicMeasurementsIfPresent( + ObjectNode visitPayload, TracabilitePei traca) { + if (!hasHydraulicMeasurements(traca)) { + return visitPayload; + } + visitPayload.put( "pression", (traca.getEssaiPressionStatique() != null) ? traca.getEssaiPressionStatique().doubleValue() : null); - v.put( + visitPayload.put( "pressionDynamique", (traca.getEssaiPressionDynamique() != null) ? traca.getEssaiPressionDynamique().doubleValue() : null); - v.put("debit", (traca.getEssaiDebit() != null) ? traca.getEssaiDebit().intValue() : null); - return v; + visitPayload.put( + "debit", (traca.getEssaiDebit() != null) ? traca.getEssaiDebit().intValue() : null); + return visitPayload; + } + + /** + * @param traca ligne de tracabilité source + * @return {@code true} si au moins une mesure hydraulique est disponible + */ + static boolean hasHydraulicMeasurements(TracabilitePei traca) { + return traca != null + && (traca.getEssaiPressionStatique() != null + || traca.getEssaiPressionDynamique() != null + || traca.getEssaiDebit() != null); + } + + /** + * @param localVisitType type de dernière visite issu de WatGIS + * @return {@code true} si cette visite doit lever tous les points d'attention + */ + static boolean shouldClearAllAnomalies(String localVisitType) { + if (localVisitType == null) { + return false; + } + String normalizedVisitType = + Normalizer.normalize(localVisitType, Normalizer.Form.NFD) + .replaceAll("\\p{M}+", "") + .toUpperCase(); + return normalizedVisitType.contains("EN SERVICE") + || normalizedVisitType.contains("CONTROLE REALISE"); + } + + /** + * @param indispoMotifs motifs d'indisponibilité remontés par WatGIS pour une synchro + * @return {@code true} si au moins un motif justifie une indisponibilité temporaire REMOcRA + */ + static boolean shouldCreateTemporaryUnavailability(List indispoMotifs) { + if (indispoMotifs == null || indispoMotifs.isEmpty()) { + return false; + } + for (String indispoMotif : indispoMotifs) { + if (isTemporaryUnavailabilityEligibleMotif(indispoMotif)) { + return true; + } + } + return false; + } + + /** + * Détermine si une remise en disponibilité doit créer une visite de levée des motifs EDP. + * + * @param trackedIndispoMotifs motifs précédemment remontés à REMOcRA + * @return {@code true} si une visite NP de levée doit être créée + */ + static boolean shouldCreateIndispoClosureVisit(List trackedIndispoMotifs) { + if (trackedIndispoMotifs == null || trackedIndispoMotifs.isEmpty()) { + return false; + } + return trackedIndispoMotifs.size() != 1 + || !"ARRET EAU".equalsIgnoreCase(trackedIndispoMotifs.get(0)); + } + + /** + * @param indispoMotif motif d'indisponibilité source + * @return {@code true} si ce motif fait partie des seuls cas métier autorisant une IT + */ + static boolean isTemporaryUnavailabilityEligibleMotif(String indispoMotif) { + if (indispoMotif == null) { + return false; + } + String normalizedMotif = + Normalizer.normalize(indispoMotif, Normalizer.Form.NFD) + .replaceAll("\\p{M}+", "") + .toUpperCase(); + return normalizedMotif.contains("ARRET EAU") || normalizedMotif.contains("RENOUVEL"); } /** @@ -920,8 +1053,8 @@ private ObjectNode recuperationsValeursDebitPression(ObjectNode v, TracabilitePe * @return Les données de la visite */ private ObjectNode recuperationAnomalies(ObjectNode v, TracabilitePei traca) - throws APIConnectionException, RequestException, JsonProcessingException, - APIAuthentException { + throws APIConnectionException, RequestException, JsonProcessingException, APIAuthentException, + InternalException { String typeDerniereVisite = (traca.getTypeDerniereVisite() != null) ? traca.getTypeDerniereVisite().toUpperCase() @@ -930,18 +1063,14 @@ private ObjectNode recuperationAnomalies(ObjectNode v, TracabilitePei traca) ObjectMapper mapper = new ObjectMapper(); ArrayNode arrayAnomaliesControlees = mapper.createArrayNode(); ArrayNode arrayAnomaliesConstatees = mapper.createArrayNode(); - ArrayList anomaliesBloquante = - this.peiRepository.getNaturesAnomaliesAccessibles( - traca.getReference(), v.get("contexte").textValue(), true); ArrayList toutesAnomalies = this.peiRepository.getNaturesAnomaliesAccessibles( - traca.getReference(), v.get("contexte").textValue(), false); + traca.getReference(), v.get("typeVisite").textValue(), false); if (typeDerniereVisite != null) { // Aucune anomalie - if ((typeDerniereVisite.contains("EN SERVICE")) - || (typeDerniereVisite.contains("CONTROLE REALISE"))) { - for (String code : anomaliesBloquante) { + if (shouldClearAllAnomalies(typeDerniereVisite)) { + for (String code : toutesAnomalies) { arrayAnomaliesControlees.add(code); } v.set("anomaliesControlees", arrayAnomaliesControlees); diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdate.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdate.java new file mode 100644 index 0000000..6376b4a --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdate.java @@ -0,0 +1,29 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** Contient le chemin cible et le payload d'écriture des caractéristiques d'un PEI. */ +public final class PeiCaracteristiquesUpdate { + + private final String path; + private final ObjectNode payload; + + /** + * @param path endpoint cible REMOcRA + * @param payload charge utile à transmettre + */ + public PeiCaracteristiquesUpdate(String path, ObjectNode payload) { + this.path = path; + this.payload = payload; + } + + /** @return endpoint cible REMOcRA */ + public String getPath() { + return path; + } + + /** @return charge utile à transmettre */ + public ObjectNode getPayload() { + return payload; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdateFactory.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdateFactory.java new file mode 100644 index 0000000..48ad2b5 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdateFactory.java @@ -0,0 +1,134 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; +import fr.eaudeparis.syncremocra.db.model.tables.pojos.TracabilitePei; +import fr.eaudeparis.syncremocra.util.JSONUtil; +import java.util.Map; + +/** Fabrique le payload v3 des caractéristiques PEI en fonction du type PIBI/PENA. */ +public final class PeiCaracteristiquesUpdateFactory { + + private PeiCaracteristiquesUpdateFactory() {} + + /** + * Construit la mise à jour des caractéristiques à transmettre à REMOcRA. + * + * @param mapper sérialiseur JSON + * @param apiEndpoints endpoints REMOcRA + * @param reference numéro complet du PEI + * @param traca données locales EDP + * @param dataRemocra caractéristiques actuellement connues côté REMOcRA + * @param codeDiametre code diamètre REMOcRA + * @param codeMarque code marque REMOcRA + * @param codeModele code modèle REMOcRA + * @return endpoint cible et payload correspondant + */ + public static PeiCaracteristiquesUpdate create( + ObjectMapper mapper, + ApiEndpoints apiEndpoints, + PeiType peiType, + String reference, + TracabilitePei traca, + Map dataRemocra, + String codeDiametre, + String codeMarque, + String codeModele) { + return PeiType.PENA.equals(peiType) + ? createPenaUpdate(mapper, apiEndpoints, reference, dataRemocra) + : createPibiUpdate( + mapper, + apiEndpoints, + reference, + traca, + dataRemocra, + codeDiametre, + codeMarque, + codeModele); + } + + private static PeiCaracteristiquesUpdate createPibiUpdate( + ObjectMapper mapper, + ApiEndpoints apiEndpoints, + String reference, + TracabilitePei traca, + Map dataRemocra, + String codeDiametre, + String codeMarque, + String codeModele) { + ObjectNode payload = mapper.createObjectNode(); + payload.put("codeDiametre", codeDiametre); + payload.put( + "diametreCanalisation", + traca.getDiametreCanalisation() != null + ? Integer.valueOf(traca.getDiametreCanalisation()) + : null); + payload.put("peiJumele", getString(dataRemocra, "jumelage", "pibiJumele")); + payload.put( + "inviolabilite", getBoolean(dataRemocra, "inviolabilite", "dispositifInviolabilite")); + payload.put("renversable", getBoolean(dataRemocra, "renversable")); + payload.put("codeMarque", codeMarque); + payload.put("codeModele", codeModele); + payload.put("anneeFabrication", getInteger(dataRemocra, "anneeFabrication")); + payload.put("codeTypeReseau", getString(dataRemocra, "natureReseau", "typeReseauCode")); + payload.put( + "codeTypeCanalisation", + getString(dataRemocra, "natureCanalisation", "typeCanalisationCode")); + payload.put("reseauSurpresse", getBoolean(dataRemocra, "reseauSurpresse", "surpresse")); + payload.put("reseauAdditive", getBoolean(dataRemocra, "reseauAdditive", "additive")); + return new PeiCaracteristiquesUpdate(apiEndpoints.peiPibiCaracteristiques(reference), payload); + } + + private static PeiCaracteristiquesUpdate createPenaUpdate( + ObjectMapper mapper, ApiEndpoints apiEndpoints, String reference, Map data) { + ObjectNode payload = mapper.createObjectNode(); + payload.put("capaciteIllimitee", getBoolean(data, "illimite", "capaciteIllimitee")); + payload.put("capaciteIncertaine", getBoolean(data, "incertaine", "capaciteIncertaine")); + payload.put("capacite", getInteger(data, "capacite")); + payload.put("quantiteAppoint", getDouble(data, "debitAppoint", "quantiteAppoint")); + payload.put("codeMateriau", getString(data, "codeMateriau", "materiauCode")); + payload.put("equipeHBE", getBoolean(data, "equipeHBE")); + return new PeiCaracteristiquesUpdate(apiEndpoints.peiPenaCaracteristiques(reference), payload); + } + + private static String getString(Map data, String... keys) { + for (String key : keys) { + String value = JSONUtil.getString(data, key); + if (value != null) { + return value; + } + } + return null; + } + + private static Boolean getBoolean(Map data, String... keys) { + for (String key : keys) { + Boolean value = JSONUtil.getBoolean(data, key); + if (value != null) { + return value; + } + } + return null; + } + + private static Integer getInteger(Map data, String... keys) { + for (String key : keys) { + Integer value = JSONUtil.getInteger(data, key); + if (value != null) { + return value; + } + } + return null; + } + + private static Double getDouble(Map data, String... keys) { + for (String key : keys) { + Double value = JSONUtil.getDouble(data, key); + if (value != null) { + return value; + } + } + return null; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiType.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiType.java new file mode 100644 index 0000000..a18cb61 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiType.java @@ -0,0 +1,18 @@ +package fr.eaudeparis.syncremocra.repository.message; + +/** Type métier de PEI utilisé pour router les mises à jour de caractéristiques. */ +public enum PeiType { + PIBI("pibi"), + PENA("pena"); + + private final String apiValue; + + PeiType(String apiValue) { + this.apiValue = apiValue; + } + + /** @return valeur attendue dans les chemins d'API REMOcRA */ + public String getApiValue() { + return apiValue; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiTypeResolver.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiTypeResolver.java new file mode 100644 index 0000000..52ad0d1 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/PeiTypeResolver.java @@ -0,0 +1,53 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import fr.eaudeparis.syncremocra.util.JSONUtil; +import java.util.List; +import java.util.Map; + +/** Résout le type PIBI/PENA d'un PEI à partir de sa nature et des référentiels REMOcRA. */ +public final class PeiTypeResolver { + + private PeiTypeResolver() {} + + /** + * Détermine le type de PEI en croisant la nature du PEI avec les référentiels PIBI et PENA. + * + * @param dataPei détail du PEI retourné par REMOcRA + * @param pibiNatures natures PEI de type PIBI + * @param penaNatures natures PEI de type PENA + * @return type résolu, ou {@code null} si la nature est absente ou non retrouvée + */ + public static PeiType resolve( + Map dataPei, + List> pibiNatures, + List> penaNatures) { + String natureIdentifier = getNatureIdentifier(dataPei); + if (natureIdentifier == null) { + return null; + } + if (containsNature(pibiNatures, natureIdentifier)) { + return PeiType.PIBI; + } + if (containsNature(penaNatures, natureIdentifier)) { + return PeiType.PENA; + } + return null; + } + + private static String getNatureIdentifier(Map dataPei) { + return JSONUtil.getString(dataPei, "peiNatureId"); + } + + private static boolean containsNature( + List> natures, String natureIdentifier) { + for (Map nature : natures) { + String natureId = JSONUtil.getString(nature, "natureId"); + String natureCode = JSONUtil.getString(nature, "natureCode"); + if (natureIdentifier.equalsIgnoreCase(String.valueOf(natureId)) + || natureIdentifier.equalsIgnoreCase(String.valueOf(natureCode))) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/VisitPayloadTracker.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/VisitPayloadTracker.java new file mode 100644 index 0000000..69726d3 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/VisitPayloadTracker.java @@ -0,0 +1,30 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Adapte le payload visite stocké localement pour conserver le suivi historique de SyncRemocra. + * + *

Le contrat REMOcRA v3 attend {@code typeVisite}, tandis que les vues SQL et notifications + * existantes lisent encore le champ legacy {@code contexte}. Le payload envoyé à l'API reste en v3, + * mais la version stockée localement conserve ce champ historique. + */ +public final class VisitPayloadTracker { + + private VisitPayloadTracker() {} + + /** + * Produit une copie du payload de visite enrichie avec le champ local legacy {@code contexte}. + * + * @param apiPayload payload destiné à l'API REMOcRA + * @return copie enrichie pour le suivi local + */ + public static ObjectNode toTrackedPayload(ObjectNode apiPayload) { + ObjectNode trackedPayload = apiPayload.deepCopy(); + if (trackedPayload.hasNonNull("typeVisite") && !trackedPayload.has("contexte")) { + trackedPayload.put( + "contexte", VisitTypeMapper.toLocalType(trackedPayload.get("typeVisite").asText())); + } + return trackedPayload; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/VisitTypeMapper.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/VisitTypeMapper.java new file mode 100644 index 0000000..3aa2362 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/message/VisitTypeMapper.java @@ -0,0 +1,43 @@ +package fr.eaudeparis.syncremocra.repository.message; + +/** Convertit les types de visite entre le modèle local SyncRemocra et REMOcRA v3. */ +public final class VisitTypeMapper { + + private VisitTypeMapper() {} + + /** + * Détermine le type de visite REMOcRA à partir du libellé de visite local. + * + * @param localVisitType libellé ou code historique WatGIS/SyncRemocra + * @return type REMOcRA v3 (`CTP` ou `NP`), ou {@code null} si le type n'est pas géré + */ + public static String toRemocraType(String localVisitType) { + if (localVisitType == null) { + return null; + } + String normalized = localVisitType.toUpperCase(); + if (normalized.startsWith("PICF") + || normalized.startsWith("PIQP") + || normalized.startsWith("CTRL") + || normalized.startsWith("CTP")) { + return "CTP"; + } + if (normalized.startsWith("NP")) { + return "NP"; + } + return null; + } + + /** + * Convertit un type de visite REMOcRA v3 vers la valeur locale historique. + * + * @param remocraVisitType type REMOcRA v3 + * @return type local stocké en base + */ + public static String toLocalType(String remocraVisitType) { + if ("CTP".equalsIgnoreCase(remocraVisitType)) { + return "CTRL"; + } + return remocraVisitType; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReference.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReference.java new file mode 100644 index 0000000..42142a8 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReference.java @@ -0,0 +1,23 @@ +package fr.eaudeparis.syncremocra.repository.pei; + +import fr.eaudeparis.syncremocra.repository.message.PeiType; + +/** Porte la nature et le type d'un PEI sous le format attendu par les référentiels REMOcRA. */ +public final class PeiNatureReference { + + private final PeiType peiType; + private final String natureCode; + + public PeiNatureReference(PeiType peiType, String natureCode) { + this.peiType = peiType; + this.natureCode = natureCode; + } + + public PeiType getPeiType() { + return peiType; + } + + public String getNatureCode() { + return natureCode; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReferenceResolver.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReferenceResolver.java new file mode 100644 index 0000000..52abe36 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReferenceResolver.java @@ -0,0 +1,55 @@ +package fr.eaudeparis.syncremocra.repository.pei; + +import fr.eaudeparis.syncremocra.repository.message.PeiType; +import java.util.List; +import java.util.Map; + +/** Résout le type et la nature d'un PEI à partir du contrat REMOcRA v3. */ +public final class PeiNatureReferenceResolver { + + private PeiNatureReferenceResolver() {} + + /** + * Détermine le type de PEI et le code nature à utiliser pour interroger les référentiels + * d'anomalies. + * + * @param pei détail du PEI + * @param pibiNatures référentiel des natures PIBI + * @param penaNatures référentiel des natures PENA + * @return la référence de nature, ou {@code null} si elle ne peut pas être résolue + */ + public static PeiNatureReference resolve( + Map pei, + List> pibiNatures, + List> penaNatures) { + String natureId = valueAsString(pei.get("peiNatureId")); + if (natureId == null) { + return null; + } + + String pibiNatureCode = findNatureCode(pibiNatures, natureId); + if (pibiNatureCode != null) { + return new PeiNatureReference(PeiType.PIBI, pibiNatureCode); + } + + String penaNatureCode = findNatureCode(penaNatures, natureId); + if (penaNatureCode != null) { + return new PeiNatureReference(PeiType.PENA, penaNatureCode); + } + + return null; + } + + private static String findNatureCode(List> natures, String natureId) { + for (Map nature : natures) { + if (natureId.equals(valueAsString(nature.get("natureId")))) { + return valueAsString(nature.get("natureCode")); + } + } + return null; + } + + private static String valueAsString(Object value) { + return value != null ? value.toString() : null; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiRepository.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiRepository.java index c3a9aa7..6496c0a 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiRepository.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/PeiRepository.java @@ -5,14 +5,15 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; import fr.eaudeparis.syncremocra.db.model.tables.pojos.VuePeiEdpRemocra; import fr.eaudeparis.syncremocra.repository.RepositoryUtil; +import fr.eaudeparis.syncremocra.repository.message.PeiType; import fr.eaudeparis.syncremocra.repository.model.SortOrder; import fr.eaudeparis.syncremocra.repository.pei.model.VuePeiEdpRemocraFilter; import fr.eaudeparis.syncremocra.repository.pei.model.VuePeiEdpRemocraSort; import fr.eaudeparis.syncremocra.util.APIAuthentException; import fr.eaudeparis.syncremocra.util.APIConnectionException; -import fr.eaudeparis.syncremocra.util.JSONUtil; import fr.eaudeparis.syncremocra.util.RequestException; import fr.eaudeparis.syncremocra.util.RequestManager; import java.util.ArrayList; @@ -41,6 +42,8 @@ public class PeiRepository { @Inject RequestManager requestManager; + @Inject ApiEndpoints apiEndpoints; + public int count(VuePeiEdpRemocraFilter filters) { return context.fetchCount(getSelect().where(getFiltersCondition(filters)).getQuery()); } @@ -93,7 +96,7 @@ private SortField getSortField(VuePeiEdpRemocraSort sortField, SortOrder sort * nature, et par la configuration qui a été réalisée sur REMOcRA * * @param reference La référence du PEI - * @param contexte Le contexte de visite (code du type de saisie) + * @param typeVisite Le type de visite REMOcRA * @param bloquante Seulement les anomalies bloquantes ou non * @return Un arraylist contenant les codes des anomalies qui sont accessibles * @throws RequestException Une erreur a été renvoyée par l'API @@ -103,46 +106,56 @@ private SortField getSortField(VuePeiEdpRemocraSort sortField, SortOrder sort * @throws APIAuthentException Impossible de s'authentifier sur l'API */ public ArrayList getNaturesAnomaliesAccessibles( - String reference, String contexte, boolean bloquante) + String reference, String typeVisite, boolean bloquante) throws RequestException, JsonProcessingException, APIConnectionException, APIAuthentException { - String dataPei = this.requestManager.sendGetRequest("/api/deci/pei/" + reference); + String dataPei = this.requestManager.sendGetRequest(apiEndpoints.pei(reference)); + String dataNaturesPibi = + this.requestManager.sendGetRequest( + apiEndpoints.referentielNaturesPei(PeiType.PIBI.getApiValue())); + String dataNaturesPena = + this.requestManager.sendGetRequest( + apiEndpoints.referentielNaturesPei(PeiType.PENA.getApiValue())); ObjectMapper mapper = new ObjectMapper(); TypeReference> typeRef = new TypeReference>() {}; Map data = mapper.readValue(dataPei, typeRef); - - String type = null; - String nature = JSONUtil.getString(data, "nature"); - if ("PIBI".equalsIgnoreCase(JSONUtil.getString(data, "type"))) { - type = "pibi"; - } else if ("PENA".equalsIgnoreCase(JSONUtil.getString(data, "type"))) { - type = "pena"; + TypeReference>> listTypeRef = + new TypeReference>>() {}; + List> pibiNatures = mapper.readValue(dataNaturesPibi, listTypeRef); + List> penaNatures = mapper.readValue(dataNaturesPena, listTypeRef); + + PeiNatureReference peiReference = + PeiNatureReferenceResolver.resolve(data, pibiNatures, penaNatures); + if (peiReference == null) { + return new ArrayList(); } String path = - "/api/deci/referentiel/" - + type - + "/" - + nature - + "/naturesAnomalies?contexteVisite=" - + contexte; + apiEndpoints.referentielNaturesAnomalies( + peiReference.getPeiType().getApiValue(), peiReference.getNatureCode()) + + "?typeVisite=" + + typeVisite; String dataAnomalies = this.requestManager.sendGetRequest(path); ArrayList anomalies = new ArrayList(); TypeReference>> typeRefAnomalies = new TypeReference>>() {}; for (Map anomalie : mapper.readValue(dataAnomalies, typeRefAnomalies)) { - String code = JSONUtil.getString(anomalie, "code"); - Integer valIndispo = JSONUtil.getInteger(anomalie, "valIndispoTerrestre"); + String code = RemocraAnomalieMapper.getCode(anomalie); + boolean bloquanteAnomalie = RemocraAnomalieMapper.isBloquante(anomalie); + boolean supportsTypeVisite = RemocraAnomalieMapper.supportsTypeVisite(anomalie, typeVisite); // Si le paramatetre bloquante et a true // on ne renvoi que les anomalies BLOQUANTE (valindispo ==5) if ((code != null && code.length() > 0 && !anomalies.contains(code)) && bloquante - && Integer.valueOf(5).equals(valIndispo)) { + && bloquanteAnomalie + && supportsTypeVisite) { anomalies.add(code); // Sinon on renvois TOUT les anomalies accessible pour tel contexte de visite - } else if ((code != null && code.length() > 0 && !anomalies.contains(code)) && !bloquante) { + } else if ((code != null && code.length() > 0 && !anomalies.contains(code)) + && !bloquante + && supportsTypeVisite) { anomalies.add(code); } } diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/RemocraAnomalieMapper.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/RemocraAnomalieMapper.java new file mode 100644 index 0000000..7d68959 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pei/RemocraAnomalieMapper.java @@ -0,0 +1,50 @@ +package fr.eaudeparis.syncremocra.repository.pei; + +import fr.eaudeparis.syncremocra.util.JSONUtil; +import java.util.List; +import java.util.Map; + +/** Mappe les anomalies des référentiels REMOcRA v3 vers le format historique SyncRemocra. */ +public final class RemocraAnomalieMapper { + + private static final int BLOQUANTE_WEIGHT = 5; + + private RemocraAnomalieMapper() {} + + /** + * @param anomalie anomalie issue du référentiel REMOcRA + * @return code de l'anomalie + */ + public static String getCode(Map anomalie) { + return JSONUtil.getString(anomalie, "anomalieCode"); + } + + /** + * @param anomalie anomalie issue du référentiel REMOcRA + * @return {@code true} si l'anomalie est bloquante + */ + public static boolean isBloquante(Map anomalie) { + Integer v3Weight = JSONUtil.getInteger(anomalie, "poidsAnomalieValIndispoTerrestre"); + return Integer.valueOf(BLOQUANTE_WEIGHT).equals(v3Weight); + } + + /** + * Détermine si l'anomalie est accessible pour un type de visite donné. + * + * @param anomalie anomalie issue du référentiel REMOcRA + * @param typeVisite type de visite REMOcRA + * @return {@code true} si l'anomalie est compatible avec ce type de visite + */ + public static boolean supportsTypeVisite(Map anomalie, String typeVisite) { + Object listTypeVisite = anomalie.get("listTypeVisite"); + if (!(listTypeVisite instanceof List)) { + return true; + } + for (Object supportedType : (List) listTypeVisite) { + if (typeVisite.equalsIgnoreCase(String.valueOf(supportedType))) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantMapper.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantMapper.java new file mode 100644 index 0000000..911950a --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantMapper.java @@ -0,0 +1,89 @@ +package fr.eaudeparis.syncremocra.repository.pullmessage; + +import fr.eaudeparis.syncremocra.db.model.tables.pojos.PullHydrant; +import fr.eaudeparis.syncremocra.util.JSONUtil; +import java.util.Map; + +/** + * Mappe les réponses REMOcRA v3 vers le schéma applicatif local des hydrants en attente. + * + *

La base locale conserve encore le modèle historique. Tant que le schéma n'évolue pas, les + * identifiants v3 de type UUID ou code sont donc stockés tels quels dans les colonnes texte + * existantes. + */ +public final class PullHydrantMapper { + + private PullHydrantMapper() {} + + /** + * Construit une vue hydrant compatible avec le schéma local à partir des réponses PEI et + * caractéristiques. + * + * @param dataPei réponse du endpoint `/deci/pei/{numeroComplet}` + * @param dataPeiCarac réponse du endpoint `/deci/pei/{numeroComplet}/caracteristiques` + * @return données hydrant prêtes à être persistées + */ + public static PullHydrant map(Map dataPei, Map dataPeiCarac) { + PullHydrant hydrant = new PullHydrant(); + hydrant.setDiametre(getString(dataPeiCarac, "pibiDiametreId")); + hydrant.setMarque(getString(dataPeiCarac, "pibiMarqueId")); + hydrant.setModele(getString(dataPeiCarac, "pibiModeleId")); + hydrant.setDiametreCanalisation(getInteger(dataPeiCarac, "pibiDiametreCanalisation")); + hydrant.setAnneeFabrication(getString(dataPeiCarac, "peiAnneeFabrication")); + if (hydrant.getAnneeFabrication() == null) { + hydrant.setAnneeFabrication(getString(dataPei, "peiAnneeFabrication")); + } + hydrant.setComplement(getString(dataPei, "peiComplementAdresse")); + hydrant.setDispoTerrestre(getString(dataPei, "peiDisponibiliteTerrestre")); + hydrant.setDispoHbe(getString(dataPei, "penaDisponibiliteHbe")); + hydrant.setNumeroVoie(getInteger(dataPei, "peiNumeroVoie")); + hydrant.setSuffixeVoie(getString(dataPei, "peiSuffixeVoie")); + hydrant.setNiveau(getString(dataPei, "peiNiveauId")); + hydrant.setVoie(getString(dataPei, "peiVoieTexte")); + hydrant.setVoie2(getString(dataPei, "peiCroisementId")); + hydrant.setEnFace(getBoolean(dataPei, "peiEnFace")); + hydrant.setDomaine(getString(dataPei, "peiDomaineId")); + hydrant.setCommune(getString(dataPei, "peiCommuneId")); + hydrant.setNature(getString(dataPei, "peiNatureId")); + hydrant.setNatureDeci(getString(dataPei, "peiNatureDeciId")); + hydrant.setIndispoTemporaire(getBoolean(dataPei, "peiIndispoTemporaire")); + return hydrant; + } + + private static String getString(Map data, String... keys) { + for (String key : keys) { + String value = JSONUtil.getString(data, key); + if (value != null) { + return value; + } + } + return null; + } + + private static Integer getInteger(Map data, String... keys) { + for (String key : keys) { + Integer value = parseInteger(JSONUtil.getString(data, key)); + if (value != null) { + return value; + } + } + return null; + } + + private static Boolean getBoolean(Map data, String... keys) { + for (String key : keys) { + Boolean value = JSONUtil.getBoolean(data, key); + if (value != null) { + return value; + } + } + return null; + } + + private static Integer parseInteger(String value) { + if (value == null || value.isEmpty() || !value.matches("-?\\d+")) { + return null; + } + return Integer.valueOf(value); + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantVisiteMapper.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantVisiteMapper.java new file mode 100644 index 0000000..fb9df56 --- /dev/null +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantVisiteMapper.java @@ -0,0 +1,60 @@ +package fr.eaudeparis.syncremocra.repository.pullmessage; + +import fr.eaudeparis.syncremocra.repository.message.VisitTypeMapper; +import fr.eaudeparis.syncremocra.util.JSONUtil; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.Map; + +/** Mappe les payloads de visites REMOcRA v3 vers le format local historique. */ +public final class PullHydrantVisiteMapper { + + private PullHydrantVisiteMapper() {} + + /** + * @param visite payload de liste de visites + * @return identifiant de visite, ou {@code null} si absent + */ + public static Object getVisiteId(Map visite) { + return visite.get("visiteId"); + } + + /** + * Convertit le type de visite REMOcRA vers le type local historique. + * + * @param dataVisiteSpecifique payload détaillé de la visite + * @return type local + */ + public static String getLocalVisitType(Map dataVisiteSpecifique) { + return VisitTypeMapper.toLocalType(JSONUtil.getString(dataVisiteSpecifique, "typeVisite")); + } + + /** + * Résout un double parmi plusieurs noms de champs compatibles. + * + * @param data payload source + * @param keys noms de champs à tester dans l'ordre + * @return première valeur trouvée + */ + public static Double getDouble(Map data, String... keys) { + for (String key : keys) { + Double value = JSONUtil.getDouble(data, key); + if (value != null) { + return value; + } + } + return null; + } + + /** + * @param dataVisiteSpecifique payload détaillé de la visite + * @return date locale de la visite, ou {@code null} si absente + */ + public static LocalDateTime getVisitDate(Map dataVisiteSpecifique) { + String moment = JSONUtil.getString(dataVisiteSpecifique, "moment"); + if (moment == null) { + return null; + } + return OffsetDateTime.parse(moment).toLocalDateTime(); + } +} diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullMessageRepository.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullMessageRepository.java index 28580d2..fdffb56 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullMessageRepository.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullMessageRepository.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; import fr.eaudeparis.syncremocra.db.model.tables.pojos.PullHydrant; import fr.eaudeparis.syncremocra.db.model.tables.pojos.TypeErreur; import fr.eaudeparis.syncremocra.repository.erreur.ErreurRepository; @@ -47,6 +48,8 @@ public class PullMessageRepository { @Inject RequestManager requestManager; + @Inject ApiEndpoints apiEndpoints; + @Inject ErreurRepository erreurRepository; /** Récupération des informations depuis Remocra */ @@ -57,8 +60,8 @@ public void recupererInformations() { logger.info("Récupération des modifications depuis " + date); Map params = new HashMap(); - params.put("date", date); - String json = this.requestManager.sendGetRequest("/api/deci/pei/diff", params); + params.put("moment", date); + String json = this.requestManager.sendGetRequest(apiEndpoints.peiDiff(), params); ObjectMapper objectMapper = new ObjectMapper(); DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); @@ -86,8 +89,10 @@ public void recupererInformations() { || pei.getDateModification().compareTo(modifVisites.getDateModification()) > 0)) { modifVisites = pei; } else if ("CARACTERISTIQUES".equals(pei.getType()) - && (modifVisites == null - || pei.getDateModification().compareTo(modifVisites.getDateModification()) > 0)) { + && (modifCaracteristiques == null + || pei.getDateModification() + .compareTo(modifCaracteristiques.getDateModification()) + > 0)) { modifCaracteristiques = pei; } } @@ -177,11 +182,7 @@ private void modificationVisite(PeiDiffModel modif) // Si les données n'ont pas été modifiées par l'organisme courant (ou un de ses utilisateurs, on // remonte l'info - if ((("ETL".equals(modif.getAuteurModificationFlag()) - || "USER".equals(modif.getAuteurModificationFlag())) - && !modif.getUtilisateurModificationOrganisme().equals(nomOrganisme)) - || ("API".equals(modif.getAuteurModificationFlag()) - && !modif.getOrganismeModification().equals(nomOrganisme))) { + if (!modif.isModifiedByCurrentOrganisme(nomOrganisme)) { this.recuperationVisites(modif.getNumero()); @@ -227,15 +228,15 @@ private void modificationCaracteristiques(PeiDiffModel modif) .where(PULL_HYDRANT.NUMERO.equalIgnoreCase(modif.getNumero())) .fetchOneInto(Long.class); - String jsonPei = this.requestManager.sendGetRequest("/api/deci/pei/" + modif.getNumero()); + String jsonPei = this.requestManager.sendGetRequest(apiEndpoints.pei(modif.getNumero())); String jsonPeiCarac = - this.requestManager.sendGetRequest( - "/api/deci/pei/" + modif.getNumero() + "/caracteristiques"); + this.requestManager.sendGetRequest(apiEndpoints.peiCaracteristiques(modif.getNumero())); ObjectMapper mapper = new ObjectMapper(); TypeReference> typeRef = new TypeReference>() {}; Map dataPei = mapper.readValue(jsonPei, typeRef); Map dataPeiCarac = mapper.readValue(jsonPeiCarac, typeRef); + PullHydrant hydrant = PullHydrantMapper.map(dataPei, dataPeiCarac); String auteurModification = ("USER".equals(modif.getAuteurModificationFlag())) @@ -259,31 +260,25 @@ private void modificationCaracteristiques(PeiDiffModel modif) .atZone(ZoneId.systemDefault()) .toLocalDateTime()) .set(PULL_HYDRANT.AUTEUR_MODIFICATION, auteurModification) - .set(PULL_HYDRANT.DIAMETRE, JSONUtil.getString(dataPeiCarac, "diametre")) - .set(PULL_HYDRANT.MARQUE, JSONUtil.getString(dataPeiCarac, "marque")) - .set(PULL_HYDRANT.MODELE, JSONUtil.getString(dataPeiCarac, "modele")) - .set( - PULL_HYDRANT.DIAMETRE_CANALISATION, - JSONUtil.getInteger(dataPeiCarac, "diametreCanalisation")) - .set( - PULL_HYDRANT.ANNEE_FABRICATION, - JSONUtil.getString(dataPeiCarac, "anneeFabrication")) - .set(PULL_HYDRANT.COMPLEMENT, JSONUtil.getString(dataPei, "complement")) - .set(PULL_HYDRANT.DISPO_TERRESTRE, JSONUtil.getString(dataPei, "dispoTerrestre")) - .set(PULL_HYDRANT.DISPO_HBE, JSONUtil.getString(dataPei, "dispoAerienne")) - .set(PULL_HYDRANT.NUMERO_VOIE, JSONUtil.getInteger(dataPei, "numeroVoie")) - .set(PULL_HYDRANT.SUFFIXE_VOIE, JSONUtil.getString(dataPei, "suffixeVoie")) - .set(PULL_HYDRANT.NIVEAU, JSONUtil.getString(dataPei, "niveau")) - .set(PULL_HYDRANT.VOIE, JSONUtil.getString(dataPei, "voie")) - .set(PULL_HYDRANT.VOIE2, JSONUtil.getString(dataPei, "carrefour")) - .set(PULL_HYDRANT.EN_FACE, JSONUtil.getBoolean(dataPei, "enFace")) - .set(PULL_HYDRANT.DOMAINE, JSONUtil.getString(dataPei, "domaine")) - .set(PULL_HYDRANT.COMMUNE, JSONUtil.getString(dataPei, "commune")) - .set(PULL_HYDRANT.NATURE, JSONUtil.getString(dataPei, "nature")) - .set(PULL_HYDRANT.NATURE_DECI, JSONUtil.getString(dataPei, "natureDeci")) - .set( - PULL_HYDRANT.INDISPO_TEMPORAIRE, - JSONUtil.getBoolean(dataPei, "indispoTemporaire")) + .set(PULL_HYDRANT.DIAMETRE, hydrant.getDiametre()) + .set(PULL_HYDRANT.MARQUE, hydrant.getMarque()) + .set(PULL_HYDRANT.MODELE, hydrant.getModele()) + .set(PULL_HYDRANT.DIAMETRE_CANALISATION, hydrant.getDiametreCanalisation()) + .set(PULL_HYDRANT.ANNEE_FABRICATION, hydrant.getAnneeFabrication()) + .set(PULL_HYDRANT.COMPLEMENT, hydrant.getComplement()) + .set(PULL_HYDRANT.DISPO_TERRESTRE, hydrant.getDispoTerrestre()) + .set(PULL_HYDRANT.DISPO_HBE, hydrant.getDispoHbe()) + .set(PULL_HYDRANT.NUMERO_VOIE, hydrant.getNumeroVoie()) + .set(PULL_HYDRANT.SUFFIXE_VOIE, hydrant.getSuffixeVoie()) + .set(PULL_HYDRANT.NIVEAU, hydrant.getNiveau()) + .set(PULL_HYDRANT.VOIE, hydrant.getVoie()) + .set(PULL_HYDRANT.VOIE2, hydrant.getVoie2()) + .set(PULL_HYDRANT.EN_FACE, hydrant.getEnFace()) + .set(PULL_HYDRANT.DOMAINE, hydrant.getDomaine()) + .set(PULL_HYDRANT.COMMUNE, hydrant.getCommune()) + .set(PULL_HYDRANT.NATURE, hydrant.getNature()) + .set(PULL_HYDRANT.NATURE_DECI, hydrant.getNatureDeci()) + .set(PULL_HYDRANT.INDISPO_TEMPORAIRE, hydrant.getIndispoTemporaire()) .returning(PULL_HYDRANT.ID) .fetchOne() .getValue(PULL_HYDRANT.ID); @@ -302,29 +297,25 @@ private void modificationCaracteristiques(PeiDiffModel modif) .atZone(ZoneId.systemDefault()) .toLocalDateTime()) .set(PULL_HYDRANT.AUTEUR_MODIFICATION, auteurModification) - .set(PULL_HYDRANT.DIAMETRE, JSONUtil.getString(dataPeiCarac, "diametre")) - .set(PULL_HYDRANT.MARQUE, JSONUtil.getString(dataPeiCarac, "marque")) - .set(PULL_HYDRANT.MODELE, JSONUtil.getString(dataPeiCarac, "modele")) - .set( - PULL_HYDRANT.DIAMETRE_CANALISATION, - JSONUtil.getInteger(dataPeiCarac, "diametreCanalisation")) - .set( - PULL_HYDRANT.ANNEE_FABRICATION, - JSONUtil.getString(dataPeiCarac, "anneeFabrication")) - .set(PULL_HYDRANT.COMPLEMENT, JSONUtil.getString(dataPei, "complement")) - .set(PULL_HYDRANT.DISPO_TERRESTRE, JSONUtil.getString(dataPei, "dispoTerrestre")) - .set(PULL_HYDRANT.DISPO_HBE, JSONUtil.getString(dataPei, "dispoAerienne")) - .set(PULL_HYDRANT.NUMERO_VOIE, JSONUtil.getInteger(dataPei, "numeroVoie")) - .set(PULL_HYDRANT.SUFFIXE_VOIE, JSONUtil.getString(dataPei, "suffixeVoie")) - .set(PULL_HYDRANT.NIVEAU, JSONUtil.getString(dataPei, "niveau")) - .set(PULL_HYDRANT.VOIE, JSONUtil.getString(dataPei, "voie")) - .set(PULL_HYDRANT.VOIE2, JSONUtil.getString(dataPei, "carrefour")) - .set(PULL_HYDRANT.EN_FACE, JSONUtil.getBoolean(dataPei, "enFace")) - .set(PULL_HYDRANT.DOMAINE, JSONUtil.getString(dataPei, "domaine")) - .set(PULL_HYDRANT.COMMUNE, JSONUtil.getString(dataPei, "commune")) - .set(PULL_HYDRANT.NATURE, JSONUtil.getString(dataPei, "nature")) - .set(PULL_HYDRANT.NATURE_DECI, JSONUtil.getString(dataPei, "natureDeci")) - .set(PULL_HYDRANT.INDISPO_TEMPORAIRE, JSONUtil.getBoolean(dataPei, "indispoTemporaire")) + .set(PULL_HYDRANT.DIAMETRE, hydrant.getDiametre()) + .set(PULL_HYDRANT.MARQUE, hydrant.getMarque()) + .set(PULL_HYDRANT.MODELE, hydrant.getModele()) + .set(PULL_HYDRANT.DIAMETRE_CANALISATION, hydrant.getDiametreCanalisation()) + .set(PULL_HYDRANT.ANNEE_FABRICATION, hydrant.getAnneeFabrication()) + .set(PULL_HYDRANT.COMPLEMENT, hydrant.getComplement()) + .set(PULL_HYDRANT.DISPO_TERRESTRE, hydrant.getDispoTerrestre()) + .set(PULL_HYDRANT.DISPO_HBE, hydrant.getDispoHbe()) + .set(PULL_HYDRANT.NUMERO_VOIE, hydrant.getNumeroVoie()) + .set(PULL_HYDRANT.SUFFIXE_VOIE, hydrant.getSuffixeVoie()) + .set(PULL_HYDRANT.NIVEAU, hydrant.getNiveau()) + .set(PULL_HYDRANT.VOIE, hydrant.getVoie()) + .set(PULL_HYDRANT.VOIE2, hydrant.getVoie2()) + .set(PULL_HYDRANT.EN_FACE, hydrant.getEnFace()) + .set(PULL_HYDRANT.DOMAINE, hydrant.getDomaine()) + .set(PULL_HYDRANT.COMMUNE, hydrant.getCommune()) + .set(PULL_HYDRANT.NATURE, hydrant.getNature()) + .set(PULL_HYDRANT.NATURE_DECI, hydrant.getNatureDeci()) + .set(PULL_HYDRANT.INDISPO_TEMPORAIRE, hydrant.getIndispoTemporaire()) .where(PULL_HYDRANT.ID.eq(Math.toIntExact(id))) .execute(); } @@ -358,7 +349,7 @@ private void recuperationVisites(String numero) .where(PULL_HYDRANT_VISITE.HYDRANT.eq(Long.valueOf(pei.getId()))) .execute(); - String jsonVisites = this.requestManager.sendGetRequest("/api/deci/pei/" + numero + "/visites"); + String jsonVisites = this.requestManager.sendGetRequest(apiEndpoints.peiVisites(numero)); ObjectMapper mapper = new ObjectMapper(); TypeReference>> typeRef = @@ -375,7 +366,7 @@ private void recuperationVisites(String numero) for (Map visite : dataVisites) { String jsonVisiteSpecifique = this.requestManager.sendGetRequest( - "/api/deci/pei/" + pei.getNumero() + "/visites/" + visite.get("identifiant")); + apiEndpoints.peiVisite(pei.getNumero(), PullHydrantVisiteMapper.getVisiteId(visite))); Map dataVisiteSpecifique = mapper.readValue(jsonVisiteSpecifique, typeRefKeyValue); @@ -386,8 +377,10 @@ private void recuperationVisites(String numero) .set(PULL_HYDRANT_VISITE.HYDRANT, Long.valueOf(pei.getId())) .set( PULL_HYDRANT_VISITE.DATE, - JSONUtil.getLocalDateTime(dataVisiteSpecifique, "date", "yyyy-MM-dd HH:mm")) - .set(PULL_HYDRANT_VISITE.TYPE, JSONUtil.getString(dataVisiteSpecifique, "contexte")) + PullHydrantVisiteMapper.getVisitDate(dataVisiteSpecifique)) + .set( + PULL_HYDRANT_VISITE.TYPE, + PullHydrantVisiteMapper.getLocalVisitType(dataVisiteSpecifique)) .set( PULL_HYDRANT_VISITE.CTRL_DEBIT_PRESSION, JSONUtil.getBoolean(dataVisiteSpecifique, "ctrlDebitPression")) @@ -402,10 +395,12 @@ private void recuperationVisites(String numero) JSONUtil.getDouble(dataVisiteSpecifique, "pression")) .set( PULL_HYDRANT_VISITE.PRESSION_DYN, - JSONUtil.getDouble(dataVisiteSpecifique, "pressionDyn")) + PullHydrantVisiteMapper.getDouble( + dataVisiteSpecifique, "pressionDyn", "pressionDynamique")) .set( PULL_HYDRANT_VISITE.PRESSION_DYN_DEB, - JSONUtil.getDouble(dataVisiteSpecifique, "pressionDynDeb")) + PullHydrantVisiteMapper.getDouble( + dataVisiteSpecifique, "pressionDynDeb", "pressionDynamiqueDebitMax")) .set( PULL_HYDRANT_VISITE.OBSERVATIONS, JSONUtil.getString(dataVisiteSpecifique, "observations")) diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/model/PeiDiffModel.java b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/model/PeiDiffModel.java index 1f31b57..db86c32 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/model/PeiDiffModel.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/repository/pullmessage/model/PeiDiffModel.java @@ -1,24 +1,40 @@ package fr.eaudeparis.syncremocra.repository.pullmessage.model; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.Date; +/** + * Modèle local des modifications PEI. + * + *

Cette classe conserve des accesseurs stables pour le reste du code tout en désérialisant le + * contrat REMOcRA v3. + */ +@JsonIgnoreProperties(ignoreUnknown = true) public class PeiDiffModel { - String numero; + @JsonProperty("numeroComplet") + private String numero; - Date dateModification; + @JsonProperty("momentModification") + private Date dateModification; - String utilisateurModification; + private String utilisateurModification; - String utilisateurModificationOrganisme; + private String utilisateurModificationOrganisme; - String organismeModification; + @JsonProperty("auteurModification") + private String organismeModification; - String auteurModificationFlag; + private String auteurModificationFlag; - String operation; + @JsonProperty("typeOperation") + private String operation; - String type; + @JsonProperty("typeObjet") + private String type; + + private Auteur auteur; public String getNumero() { return numero; @@ -37,7 +53,7 @@ public void setDateModification(Date dateModification) { } public String getUtilisateurModification() { - return utilisateurModification; + return utilisateurModification != null ? utilisateurModification : organismeModification; } public void setUtilisateurModification(String utilisateurModification) { @@ -61,7 +77,16 @@ public void setOrganismeModification(String organismeModification) { } public String getAuteurModificationFlag() { - return auteurModificationFlag; + if (auteurModificationFlag != null) { + return auteurModificationFlag; + } + if (auteur == null || auteur.typeSourceModification == null) { + return null; + } + if ("API".equalsIgnoreCase(auteur.typeSourceModification)) { + return "API"; + } + return "USER"; } public void setAuteurModificationFlag(String auteurModificationFlag) { @@ -77,10 +102,61 @@ public void setOperation(String operation) { } public String getType() { + if ("PEI".equalsIgnoreCase(type)) { + return "CARACTERISTIQUES"; + } + if ("VISITE".equalsIgnoreCase(type)) { + return "VISITES"; + } return type; } public void setType(String type) { this.type = type; } + + public Auteur getAuteur() { + return auteur; + } + + public void setAuteur(Auteur auteur) { + this.auteur = auteur; + } + + /** + * Indique si la modification provient de l'organisme courant et doit donc être ignorée côté pull. + * + *

Avec le contrat v3, l'organisme d'un auteur web/mobile n'est plus disponible dans le diff. + * Dans ce cas, on reste conservateur et on considère la modification comme externe. + * + * @param nomOrganisme Organisme courant côté sync + * @return {@code true} si la modification doit être considérée comme locale + */ + public boolean isModifiedByCurrentOrganisme(String nomOrganisme) { + String auteurFlag = getAuteurModificationFlag(); + if (auteurFlag == null || nomOrganisme == null) { + return false; + } + if ("API".equals(auteurFlag)) { + return nomOrganisme.equals(getOrganismeModification()); + } + if ("USER".equals(auteurFlag) || "ETL".equals(auteurFlag)) { + return nomOrganisme.equals(getUtilisateurModificationOrganisme()); + } + return false; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Auteur { + @JsonProperty("typeSourceModification") + private String typeSourceModification; + + public String getTypeSourceModification() { + return typeSourceModification; + } + + public void setTypeSourceModification(String typeSourceModification) { + this.typeSourceModification = typeSourceModification; + } + } } diff --git a/app/src/main/java/fr/eaudeparis/syncremocra/util/RequestManager.java b/app/src/main/java/fr/eaudeparis/syncremocra/util/RequestManager.java index 0aac12e..6d2a372 100644 --- a/app/src/main/java/fr/eaudeparis/syncremocra/util/RequestManager.java +++ b/app/src/main/java/fr/eaudeparis/syncremocra/util/RequestManager.java @@ -1,71 +1,146 @@ package fr.eaudeparis.syncremocra.util; +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; import fr.eaudeparis.syncremocra.api.ApiSettings; import fr.eaudeparis.syncremocra.repository.erreur.ErreurRepository; import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.inject.Inject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class RequestManager { + private static final long TOKEN_EXPIRY_SAFETY_WINDOW_MILLIS = 60_000L; + private static final String API_AUTHENT_ERROR_CODE = "0200"; + private static final String API_AUTHENT_ERROR_MESSAGE = + "Authentification refusée à l'API Remocra"; + private static final String API_CONNECTION_ERROR_CODE = "0003"; + private static final String API_CONNECTION_ERROR_MESSAGE = + "Impossible d'établir une connexion avec l'API Remocra"; + private static final Pattern ERROR_CODE_PATTERN = Pattern.compile("^([A-Z]?\\d{4})\\b"); private static Logger logger = LoggerFactory.getLogger(RequestManager.class); private final ApiSettings settings; + private final ApiEndpoints apiEndpoints; + private final ErrorReporter errorReporter; + private final ObjectMapper mapper = new ObjectMapper(); + private String cachedAuthorizationHeader; + private long cachedAuthorizationHeaderExpiresAtMillis; @Inject private ErreurRepository erreurRepository; @Inject - RequestManager(ApiSettings settings) { + RequestManager(ApiSettings settings, ApiEndpoints apiEndpoints) { + this(settings, apiEndpoints, null); + } + + /** + * Constructeur dédié aux tests pour remplacer le mécanisme de remontée d'erreurs. + * + * @param settings Configuration API + * @param apiEndpoints Fournisseur des chemins d'API centralisés + * @param errorReporter Reporteur d'erreurs de test, ou {@code null} pour utiliser le dépôt + */ + RequestManager(ApiSettings settings, ApiEndpoints apiEndpoints, ErrorReporter errorReporter) { this.settings = settings; + this.apiEndpoints = apiEndpoints; + this.errorReporter = errorReporter; } /** * Fonction d'authentification à l'API REMOcRA. Cette fonction est appelée avant chaque requête * - * @return String le token d'identification JWT + * @return String le header Authorization complet * @throws APIConnectionException Impossible de contacter l'API * @throws APIAuthentException Impossible de s'authentifier à l'API */ private String authenticateToRemocra() throws APIConnectionException, APIAuthentException { + if (!"keycloak".equalsIgnoreCase(settings.authType())) { + logger.warn( + "Mode d'authentification API REMOcRA non supporté en v3 only: {}", settings.authType()); + throwAuthenticationException(); + } + return authenticateToKeycloak(); + } + + private synchronized String authenticateToKeycloak() + throws APIConnectionException, APIAuthentException { + long now = System.currentTimeMillis(); + if (cachedAuthorizationHeader != null + && now + TOKEN_EXPIRY_SAFETY_WINDOW_MILLIS < cachedAuthorizationHeaderExpiresAtMillis) { + return cachedAuthorizationHeader; + } + + validateKeycloakSettings(); + URL url; HttpURLConnection conn = null; try { - url = new URL(settings.host() + "/authentication/jwt?email=" + settings.mail()); + url = + new URL( + trimTrailingSlash(settings.keycloakUrl()) + + "/realms/" + + encodePathSegment(settings.keycloakRealm()) + + "/protocol/openid-connect/token"); conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setDoInput(true); - conn.setRequestProperty("X-password", settings.password()); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + + String formData = + "grant_type=client_credentials" + + "&client_id=" + + encode(settings.keycloakClientId()) + + "&client_secret=" + + encode(settings.keycloakClientSecret()); + byte[] out = formData.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(out.length); + conn.connect(); + try (OutputStream os = conn.getOutputStream()) { + os.write(out); + } - Integer codeRetour = conn.getResponseCode(); + int codeRetour = conn.getResponseCode(); + if (codeRetour == HttpURLConnection.HTTP_OK) { + Map tokenResponse = mapper.readValue(readStream(conn.getInputStream()), Map.class); + Object accessToken = tokenResponse.get("access_token"); + if (accessToken == null || String.valueOf(accessToken).isEmpty()) { + throwAuthenticationException(); + } - if (codeRetour != null && conn.getResponseCode() == HttpURLConnection.HTTP_OK) { - return conn.getHeaderField("Authorization"); - } else { - // Erreur authentification à l'API - this.erreurRepository.addError("0200", "Authentification refusée à l'API Remocra", null); - throw new APIAuthentException(); + Number expiresIn = + tokenResponse.get("expires_in") instanceof Number + ? (Number) tokenResponse.get("expires_in") + : Integer.valueOf(300); + cachedAuthorizationHeader = "Bearer " + accessToken; + cachedAuthorizationHeaderExpiresAtMillis = now + expiresIn.longValue() * 1000L; + return cachedAuthorizationHeader; } + + logger.warn("Keycloak authentication refused: {}", readStream(conn.getErrorStream())); + throwAuthenticationException(); } catch (IOException e) { - logger.warn("Error : ", e); - // Impossible de contacter l'API - this.erreurRepository.addError( - "0003", "Impossible d'établir une connexion avec l'API Remocra", null); - throw new APIConnectionException(); + throwConnectionException(e); } finally { if (conn != null) { conn.disconnect(); } } + return null; } /** @@ -89,7 +164,7 @@ public Integer sendRequest(String method, String path, String jsonData) HttpURLConnection conn = null; String token = this.authenticateToRemocra(); try { - url = new URL(settings.host() + path); + url = new URL(buildUrl(path)); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod(method); @@ -114,24 +189,15 @@ public Integer sendRequest(String method, String path, String jsonData) if (codeRetour == HttpURLConnection.HTTP_OK || codeRetour == HttpURLConnection.HTTP_CREATED) { return codeRetour; + } else if (codeRetour == HttpURLConnection.HTTP_UNAUTHORIZED) { + logAuthenticationFailure(method, path, codeRetour, readStream(conn.getErrorStream())); + throwAuthenticationException(); } else { - StringBuilder sb = new StringBuilder(); - BufferedReader br = new BufferedReader(new InputStreamReader((conn.getErrorStream()))); - String output; - while ((output = br.readLine()) != null) { - sb.append(output); - } - response = sb.toString(); - br.close(); - - String errorCode = (response.split(" ").length > 0) ? response.split(" ")[0] : null; - - // La requête a bien été envoyée mais l'API a retourné une erreur - throw new RequestException(conn.getResponseCode(), errorCode, response); + response = readStream(conn.getErrorStream()); + throw buildRequestException(method, path, codeRetour, response, jsonData); } } catch (IOException e) { - logger.warn("Error : ", e); - e.printStackTrace(); + throwConnectionException(e); } finally { if (conn != null) { conn.disconnect(); @@ -158,20 +224,23 @@ public String sendGetRequest(String path, Map parameters) // Si des paramètres sont fournis if (parameters != null && parameters.size() > 0) { - path = path + "?"; + path = path + (path.contains("?") ? "&" : "?"); int nb = 0; for (String i : parameters.keySet()) { + if (parameters.get(i) == null) { + continue; + } if (nb > 0) { path = path + "&"; } - path = path + i + "=" + parameters.get(i).replaceAll(" ", "%20"); + path = path + encode(i) + "=" + encode(parameters.get(i)); nb++; } } logger.info("Send request to : " + path); - url = new URL(settings.host() + path); + url = new URL(buildUrl(path)); conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("GET"); @@ -183,32 +252,18 @@ public String sendGetRequest(String path, Map parameters) int codeRetour = conn.getResponseCode(); if (codeRetour == HttpURLConnection.HTTP_OK || codeRetour == HttpURLConnection.HTTP_CREATED) { - BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream())); - String inputLine; - StringBuffer content = new StringBuffer(); - while ((inputLine = in.readLine()) != null) { - content.append(inputLine); - } - in.close(); - logger.debug("get response : " + content.toString()); - return content.toString(); + response = readStream(conn.getInputStream()); + logger.debug("get response : " + response); + return response; + } else if (codeRetour == HttpURLConnection.HTTP_UNAUTHORIZED) { + logAuthenticationFailure("GET", path, codeRetour, readStream(conn.getErrorStream())); + throwAuthenticationException(); } else { - StringBuilder sb = new StringBuilder(); - BufferedReader br = new BufferedReader(new InputStreamReader((conn.getErrorStream()))); - String output; - while ((output = br.readLine()) != null) { - sb.append(output); - } - response = sb.toString(); - br.close(); - - String errorCode = (response.split(" ").length > 0) ? response.split(" ")[0] : null; - // La requête a bien été envoyée mais l'API a retourné une erreur - throw new RequestException(conn.getResponseCode(), errorCode, response); + response = readStream(conn.getErrorStream()); + throw buildRequestException("GET", path, codeRetour, response, null); } } catch (IOException e) { - logger.warn("Error : ", e); - e.printStackTrace(); + throwConnectionException(e); } finally { if (conn != null) { conn.disconnect(); @@ -221,4 +276,281 @@ public String sendGetRequest(String path) throws APIConnectionException, RequestException, APIAuthentException { return this.sendGetRequest(path, null); } + + /** + * Construit l'URL absolue à partir de l'hôte, d'un préfixe optionnel et du chemin demandé. + * + * @param path Chemin relatif de l'endpoint + * @return URL absolue prête à être appelée + */ + private String buildUrl(String path) { + return trimTrailingSlash(settings.host()) + + normalizePath(settings.basePath()) + + normalizePath(path); + } + + /** + * Normalise un chemin afin qu'il soit vide ou préfixé par un slash. + * + * @param path Chemin à normaliser + * @return Chemin normalisé + */ + private String normalizePath(String path) { + if (path == null || path.isEmpty() || "/".equals(path)) { + return ""; + } + return path.startsWith("/") ? path : "/" + path; + } + + /** + * Supprime les slashs terminaux d'une URL ou d'un chemin de base. + * + * @param value Valeur à nettoyer + * @return Valeur sans slash terminal + */ + private String trimTrailingSlash(String value) { + if (value == null) { + return ""; + } + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value; + } + + /** + * Encode une valeur pour l'utiliser dans une query string. + * + * @param value Valeur à encoder + * @return Valeur encodée en UTF-8 + */ + private String encode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } catch (IOException e) { + throw new IllegalStateException("UTF-8 encoding is not available", e); + } + } + + /** + * Encode un segment de chemin en conservant les slashs déjà présents. + * + * @param value Valeur à encoder + * @return Segment encodé + */ + private String encodePathSegment(String value) { + return encode(value).replace("%2F", "/"); + } + + /** + * Lit entièrement un flux HTTP en chaîne UTF-8. + * + * @param inputStream Flux à lire + * @return Contenu du flux, ou chaîne vide si le flux est nul + * @throws IOException Erreur de lecture + */ + private String readStream(InputStream inputStream) throws IOException { + if (inputStream == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = + new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + String output; + while ((output = br.readLine()) != null) { + sb.append(output); + } + } + return sb.toString(); + } + + /** + * Vérifie que la configuration minimale Keycloak est présente avant d'appeler OIDC. + * + * @throws APIAuthentException Configuration incomplète + */ + private void validateKeycloakSettings() throws APIAuthentException { + if (settings.keycloakUrl().isEmpty() + || settings.keycloakRealm().isEmpty() + || settings.keycloakClientId().isEmpty() + || settings.keycloakClientSecret().isEmpty()) { + logger.warn("Configuration Keycloak incomplète pour l'authentification API REMOcRA"); + throwAuthenticationException(); + } + } + + /** + * Remonte l'erreur métier historique d'authentification API avant de lever l'exception. + * + * @throws APIAuthentException Toujours levée + */ + private void throwAuthenticationException() throws APIAuthentException { + reportError(API_AUTHENT_ERROR_CODE, API_AUTHENT_ERROR_MESSAGE, null); + throw new APIAuthentException(); + } + + /** + * Remonte une erreur de connexion puis lève l'exception dédiée. + * + * @param e Cause d'origine + * @throws APIConnectionException Toujours levée + */ + private void throwConnectionException(IOException e) throws APIConnectionException { + logger.warn("Error : ", e); + reportError(API_CONNECTION_ERROR_CODE, API_CONNECTION_ERROR_MESSAGE, null); + throw new APIConnectionException(); + } + + /** + * Journalise un refus d'authentification retourné par l'API métier. + * + * @param method Méthode HTTP appelée + * @param path Chemin relatif appelé + * @param statusCode Code HTTP retourné + * @param responseBody Corps de réponse éventuel + */ + private void logAuthenticationFailure( + String method, String path, int statusCode, String responseBody) { + logger.warn( + "HTTP {} lors de l'appel {} {}. Reponse: {}", + statusCode, + method, + path, + normalizeResponseBody(responseBody)); + } + + /** + * Construit une exception explicite à partir d'un retour HTTP en erreur. + * + * @param method Méthode HTTP appelée + * @param path Chemin relatif appelé + * @param statusCode Code HTTP retourné + * @param responseBody Corps de réponse d'erreur éventuel + * @return Exception métier prête à être propagée + */ + private RequestException buildRequestException( + String method, String path, int statusCode, String responseBody, String requestBody) { + String normalizedBody = normalizeResponseBody(responseBody); + String errorCode = extractErrorCode(normalizedBody); + String message = buildHttpErrorMessage(method, path, statusCode, normalizedBody, requestBody); + logger.warn(message); + return new RequestException(statusCode, errorCode, message); + } + + /** + * Construit un message d'erreur HTTP homogène et enrichi pour les cas connus côté API métier. + * + * @param method Méthode HTTP appelée + * @param path Chemin relatif appelé + * @param statusCode Code HTTP retourné + * @param normalizedBody Corps de réponse normalisé + * @param requestBody Corps de requête éventuellement transmis + * @return Message enrichi prêt à être journalisé + */ + private String buildHttpErrorMessage( + String method, String path, int statusCode, String normalizedBody, String requestBody) { + String message = + String.format( + "HTTP %d lors de l'appel %s %s. Reponse: %s", statusCode, method, path, normalizedBody); + if (isPibiCaracteristiquesReferentialError(method, path, statusCode, normalizedBody)) { + return message + buildPibiCaracteristiquesDiagnostic(requestBody); + } + return message; + } + + /** + * Détecte le rejet serveur observé lorsque le référentiel marque/modèle PIBI ne correspond pas. + * + * @param method Méthode HTTP + * @param path Chemin appelé + * @param statusCode Code HTTP + * @param normalizedBody Réponse normalisée + * @return {@code true} si le motif métier connu est détecté + */ + private boolean isPibiCaracteristiquesReferentialError( + String method, String path, int statusCode, String normalizedBody) { + return statusCode == HttpURLConnection.HTTP_INTERNAL_ERROR + && "PUT".equalsIgnoreCase(method) + && path != null + && path.endsWith("/pibi-caracteristiques") + && "Collection contains no element matching the predicate.".equals(normalizedBody); + } + + /** + * Ajoute un diagnostic ciblé sur le couple marque/modèle transmis au endpoint PIBI. + * + * @param requestBody Corps de requête JSON envoyé à l'API + * @return Complément de message + */ + private String buildPibiCaracteristiquesDiagnostic(String requestBody) { + String codeMarque = extractRequestField(requestBody, "codeMarque"); + String codeModele = extractRequestField(requestBody, "codeModele"); + return String.format( + " Diagnostic: verifier le couple codeMarque/codeModele transmis au referentiel REMOcRA" + + " (codeMarque=%s, codeModele=%s).", + codeMarque != null ? codeMarque : "", codeModele != null ? codeModele : ""); + } + + /** + * Extrait une propriété textuelle simple d'un corps JSON de requête. + * + * @param requestBody Corps JSON + * @param fieldName Champ recherché + * @return Valeur textuelle ou {@code null} + */ + private String extractRequestField(String requestBody, String fieldName) { + if (requestBody == null || requestBody.trim().isEmpty()) { + return null; + } + try { + return mapper.readTree(requestBody).path(fieldName).asText(null); + } catch (IOException e) { + return null; + } + } + + /** + * Extrait le code d'erreur métier lorsque le corps de réponse commence par un format reconnu. + * + * @param responseBody Corps de réponse HTTP + * @return Code métier ou {@code null} + */ + private String extractErrorCode(String responseBody) { + Matcher matcher = ERROR_CODE_PATTERN.matcher(responseBody); + return matcher.find() ? matcher.group(1) : null; + } + + /** + * Nettoie le corps d'erreur pour le rendre plus lisible dans les logs. + * + * @param responseBody Corps brut + * @return Corps nettoyé ou marqueur explicite si vide + */ + private String normalizeResponseBody(String responseBody) { + if (responseBody == null) { + return ""; + } + String normalized = responseBody.trim().replaceAll("\\s+", " "); + return normalized.isEmpty() ? "" : normalized; + } + + /** + * Centralise la remontée d'erreurs pour permettre les tests sans base de données. + * + * @param codeErreur Code métier d'erreur + * @param message Message descriptif + * @param idMessage Message métier associé + */ + private void reportError(String codeErreur, String message, Long idMessage) { + if (errorReporter != null) { + errorReporter.addError(codeErreur, message, idMessage); + return; + } + this.erreurRepository.addError(codeErreur, message, idMessage); + } + + @FunctionalInterface + interface ErrorReporter { + void addError(String codeErreur, String message, Long idMessage); + } } diff --git a/app/src/main/resources/reference.conf b/app/src/main/resources/reference.conf index b74eb02..8524f44 100644 --- a/app/src/main/resources/reference.conf +++ b/app/src/main/resources/reference.conf @@ -54,11 +54,19 @@ edp = { incoherence_mail = ${?NOTIFICATION_INCOHERENCE_MAIL} } api = { - mail = "serviceeaux@atolcd.com" - mail = ${?API_MAIL} - password = "remocra1975" - password = ${?API_PASSWORD} host = "http://localhost:8881" host = ${?API_HOST} + base_path = "" + base_path = ${?API_BASE_PATH} + auth_type = "keycloak" + auth_type = ${?API_AUTH_TYPE} + keycloak_url = "http://localhost:8080" + keycloak_url = ${?API_KEYCLOAK_URL} + keycloak_realm = "remocra" + keycloak_realm = ${?API_KEYCLOAK_REALM} + keycloak_client_id = "" + keycloak_client_id = ${?API_KEYCLOAK_CLIENT_ID} + keycloak_client_secret = "" + keycloak_client_secret = ${?API_KEYCLOAK_CLIENT_SECRET} } } diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/api/ApiEndpointsTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/api/ApiEndpointsTest.java new file mode 100644 index 0000000..37e0131 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/api/ApiEndpointsTest.java @@ -0,0 +1,34 @@ +package fr.eaudeparis.syncremocra.api; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class ApiEndpointsTest { + + private final ApiEndpoints apiEndpoints = new ApiEndpoints(); + + @Test + public void shouldBuildPeiEndpoints() { + assertEquals("/deci/pei/diff", apiEndpoints.peiDiff()); + assertEquals("/deci/pei/PEI-001", apiEndpoints.pei("PEI-001")); + assertEquals("/deci/pei/PEI-001/caracteristiques", apiEndpoints.peiCaracteristiques("PEI-001")); + assertEquals( + "/deci/pei/PEI-001/pibi-caracteristiques", apiEndpoints.peiPibiCaracteristiques("PEI-001")); + assertEquals( + "/deci/pei/PEI-001/pena-caracteristiques", apiEndpoints.peiPenaCaracteristiques("PEI-001")); + assertEquals("/deci/pei/PEI-001/visites", apiEndpoints.peiVisites("PEI-001")); + assertEquals("/deci/pei/PEI-001/visites/42", apiEndpoints.peiVisite("PEI-001", 42)); + assertEquals("/deci/referentiel/pibi/naturesPEI", apiEndpoints.referentielNaturesPei("pibi")); + assertEquals("/deci/referentiel/pena/naturesPEI", apiEndpoints.referentielNaturesPei("pena")); + } + + @Test + public void shouldBuildReferentielAndIndispoEndpoints() { + assertEquals("/deci/indispoTemporaire", apiEndpoints.indispoTemporaire()); + assertEquals("/deci/indispoTemporaire/99", apiEndpoints.indispoTemporaire(99)); + assertEquals( + "/deci/referentiel/pibi/NAT-01/naturesAnomalies", + apiEndpoints.referentielNaturesAnomalies("pibi", "NAT-01")); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/db/MigrationV139Test.java b/app/src/test/java/fr/eaudeparis/syncremocra/db/MigrationV139Test.java new file mode 100644 index 0000000..1ffc94e --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/db/MigrationV139Test.java @@ -0,0 +1,27 @@ +package fr.eaudeparis.syncremocra.db; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.Test; + +/** Verifies the required EDP temporary-unavailability mappings are shipped in migration V1.39. */ +public class MigrationV139Test { + + @Test + public void shouldMapSsqAndProjetMotifsToBSPPAPSE() throws IOException { + try (InputStream migration = + getClass() + .getClassLoader() + .getResourceAsStream("db/migration/V1.39__ajout_motifs_indispo_ssq_projet.sql")) { + assertNotNull(migration); + + String sql = new String(migration.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(sql.contains("('BSPP_APSE', 'FERME DEMANDE SSQ')")); + assertTrue(sql.contains("('BSPP_APSE', 'APP PROJET')")); + } + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/erreur/ErreurRepositoryTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/erreur/ErreurRepositoryTest.java new file mode 100644 index 0000000..ee00fae --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/erreur/ErreurRepositoryTest.java @@ -0,0 +1,96 @@ +package fr.eaudeparis.syncremocra.repository.erreur; + +import static org.junit.Assert.assertFalse; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import org.jooq.DSLContext; +import org.junit.Test; + +public class ErreurRepositoryTest { + + @Test + public void shouldIgnoreUnknownErrorCodeWithoutTryingToInsert() { + TrackingHandler handler = new TrackingHandler(); + DSLContext context = + (DSLContext) + Proxy.newProxyInstance( + DSLContext.class.getClassLoader(), new Class[] {DSLContext.class}, handler); + + ErreurRepository repository = new ErreurRepository(context); + + repository.addError("HTML_ERROR", null, 51514L); + + assertFalse(handler.insertCalled); + } + + private static final class TrackingHandler implements InvocationHandler { + private boolean insertCalled; + + @Override + public Object invoke(Object proxy, Method method, Object[] args) { + String methodName = method.getName(); + + if ("selectFrom".equals(methodName) || "where".equals(methodName)) { + return proxyFor(method.getReturnType(), this); + } + if ("fetchOneInto".equals(methodName)) { + return null; + } + if ("insertInto".equals(methodName)) { + insertCalled = true; + return proxyFor(method.getReturnType(), this); + } + if ("toString".equals(methodName)) { + return "TrackingProxy"; + } + if ("hashCode".equals(methodName)) { + return System.identityHashCode(proxy); + } + if ("equals".equals(methodName)) { + return proxy == args[0]; + } + + return defaultValue(method.getReturnType()); + } + + private static Object proxyFor(Class type, InvocationHandler handler) { + if (type == null || !type.isInterface()) { + return defaultValue(type); + } + return Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, handler); + } + + private static Object defaultValue(Class type) { + if (type == null || !type.isPrimitive()) { + return null; + } + if (boolean.class.equals(type)) { + return false; + } + if (byte.class.equals(type)) { + return (byte) 0; + } + if (short.class.equals(type)) { + return (short) 0; + } + if (int.class.equals(type)) { + return 0; + } + if (long.class.equals(type)) { + return 0L; + } + if (float.class.equals(type)) { + return 0F; + } + if (double.class.equals(type)) { + return 0D; + } + if (char.class.equals(type)) { + return '\0'; + } + return null; + } + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/IndispoTemporaireMapperTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/IndispoTemporaireMapperTest.java new file mode 100644 index 0000000..28d91c4 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/IndispoTemporaireMapperTest.java @@ -0,0 +1,69 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class IndispoTemporaireMapperTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void shouldBuildV3CreatePayload() { + ObjectNode payload = + IndispoTemporaireMapper.buildCreatePayload( + mapper, "PEI-001", LocalDateTime.of(2026, 6, 5, 10, 15)); + + assertEquals("Mise en indisponibilité Eau de Paris", payload.get("motif").asText()); + assertEquals(true, payload.get("mailAvantIndisponibilite").asBoolean()); + assertEquals(true, payload.get("mailApresIndisponibilite").asBoolean()); + assertEquals(true, payload.get("basculeAutoDisponible").asBoolean()); + assertEquals(true, payload.get("basculeAutoIndisponible").asBoolean()); + assertEquals("PEI-001", payload.get("listeNumeroPei").get(0).asText()); + assertTrue(payload.get("dateDebut").asText().startsWith("2026-06-05T10:15:00")); + } + + @Test + public void shouldBuildV3UpdatePayload() { + Map v3Indispo = new HashMap<>(); + v3Indispo.put("indisponibiliteTemporaireId", "uuid-1"); + v3Indispo.put("indisponibiliteTemporaireDateDebut", "2026-06-05T09:00:00+02:00"); + + ObjectNode payload = + IndispoTemporaireMapper.buildUpdatePayload( + mapper, "PEI-001", v3Indispo, LocalDateTime.of(2026, 6, 5, 11, 30)); + + assertEquals("2026-06-05T09:00:00+02:00", payload.get("dateDebut").asText()); + assertTrue(payload.get("dateFin").asText().startsWith("2026-06-05T11:30:00")); + assertEquals("uuid-1", String.valueOf(IndispoTemporaireMapper.getIndispoId(v3Indispo))); + assertEquals("2026-06-05T09:00:00+02:00", IndispoTemporaireMapper.getDateDebut(v3Indispo)); + } + + @Test + public void shouldFindActiveIndispoFromV3Contract() { + List> indispos = new ArrayList>(); + + Map v3Active = new HashMap<>(); + v3Active.put("indisponibiliteTemporaireId", "uuid-1"); + v3Active.put("listeNumeroPei", List.of("PEI-001", "PEI-002")); + v3Active.put("indisponibiliteTemporaireDateDebut", "2026-06-05T09:00:00+02:00"); + indispos.add(v3Active); + + Map activeIndispo = + IndispoTemporaireMapper.findActiveIndispo(indispos, "PEI-001"); + + assertNotNull(activeIndispo); + assertEquals("uuid-1", String.valueOf(activeIndispo.get("indisponibiliteTemporaireId"))); + assertNull(IndispoTemporaireMapper.findActiveIndispo(indispos, "PEI-999")); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/MessageRepositoryBusinessRulesTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/MessageRepositoryBusinessRulesTest.java new file mode 100644 index 0000000..1077689 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/MessageRepositoryBusinessRulesTest.java @@ -0,0 +1,101 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import fr.eaudeparis.syncremocra.db.model.tables.pojos.TracabilitePei; +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; + +public class MessageRepositoryBusinessRulesTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void shouldClearAllAnomaliesForControlRealiseAndEnService() { + assertTrue( + MessageRepository.shouldClearAllAnomalies( + "Contrôle périodique - Point eau incendie Controle Réalisé")); + assertTrue(MessageRepository.shouldClearAllAnomalies("PEI EN SERVICE")); + assertFalse(MessageRepository.shouldClearAllAnomalies("Intervention PB signaletique")); + assertFalse(MessageRepository.shouldClearAllAnomalies(null)); + } + + @Test + public void shouldAppendHydraulicMeasurementsWhenPresent() { + TracabilitePei traca = new TracabilitePei(); + traca.setEssaiPressionStatique(BigDecimal.valueOf(2.4d)); + traca.setEssaiPressionDynamique(BigDecimal.valueOf(1.3d)); + traca.setEssaiDebit(BigDecimal.valueOf(90)); + + ObjectNode payload = + MessageRepository.appendHydraulicMeasurementsIfPresent(mapper.createObjectNode(), traca); + + assertTrue(MessageRepository.hasHydraulicMeasurements(traca)); + assertEquals(2.4d, payload.get("pression").asDouble(), 0.0d); + assertEquals(1.3d, payload.get("pressionDynamique").asDouble(), 0.0d); + assertEquals(90, payload.get("debit").asInt()); + } + + @Test + public void shouldKeepPayloadUnchangedWhenHydraulicMeasurementsAreMissing() { + TracabilitePei traca = new TracabilitePei(); + ObjectNode payload = mapper.createObjectNode(); + payload.put("typeVisite", "NP"); + + ObjectNode enrichedPayload = + MessageRepository.appendHydraulicMeasurementsIfPresent(payload, traca); + + assertFalse(MessageRepository.hasHydraulicMeasurements(traca)); + assertEquals("NP", enrichedPayload.get("typeVisite").asText()); + assertFalse(enrichedPayload.has("pression")); + assertFalse(enrichedPayload.has("pressionDynamique")); + assertFalse(enrichedPayload.has("debit")); + } + + @Test + public void shouldCreateTemporaryUnavailabilityOnlyForAuthorizedMotifs() { + assertTrue( + MessageRepository.shouldCreateTemporaryUnavailability( + Arrays.asList("APPAREIL A RENOUVELER"))); + assertTrue( + MessageRepository.shouldCreateTemporaryUnavailability( + Arrays.asList("ARRET EAU", "INACCESSIBLE : SOUS TERRASSE"))); + assertFalse(MessageRepository.shouldCreateTemporaryUnavailability(Arrays.asList("SANS EAU"))); + assertFalse( + MessageRepository.shouldCreateTemporaryUnavailability( + Arrays.asList("APP CHANTIER", "INACCESSIBLE : DANS EMPRISE DE CHANTIER"))); + assertFalse(MessageRepository.shouldCreateTemporaryUnavailability(Collections.emptyList())); + assertFalse(MessageRepository.shouldCreateTemporaryUnavailability(null)); + } + + @Test + public void shouldKeepCase17And18MotifsOutOfTemporaryUnavailabilityFlow() { + assertFalse(MessageRepository.isTemporaryUnavailabilityEligibleMotif("APP CHANTIER")); + assertFalse( + MessageRepository.isTemporaryUnavailabilityEligibleMotif( + "INACCESSIBLE : DANS EMPRISE DE CHANTIER")); + assertFalse( + MessageRepository.shouldCreateTemporaryUnavailability( + Arrays.asList( + "APP CHANTIER", + "INACCESSIBLE : DANS EMPRISE DE CHANTIER", + "INACCESSIBLE : SOUS TERRASSE"))); + } + + @Test + public void shouldCreateClosureVisitOnlyForTrackedMotifsOtherThanArretEauAlone() { + assertTrue(MessageRepository.shouldCreateIndispoClosureVisit(Arrays.asList("APP CHANTIER"))); + assertTrue( + MessageRepository.shouldCreateIndispoClosureVisit( + Arrays.asList("APP CHANTIER", "ARRET EAU"))); + assertFalse(MessageRepository.shouldCreateIndispoClosureVisit(Arrays.asList("ARRET EAU"))); + assertFalse(MessageRepository.shouldCreateIndispoClosureVisit(Collections.emptyList())); + assertFalse(MessageRepository.shouldCreateIndispoClosureVisit(null)); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdateFactoryTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdateFactoryTest.java new file mode 100644 index 0000000..82cd706 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/PeiCaracteristiquesUpdateFactoryTest.java @@ -0,0 +1,106 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import static org.junit.Assert.assertEquals; + +import com.fasterxml.jackson.databind.ObjectMapper; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; +import fr.eaudeparis.syncremocra.db.model.tables.pojos.TracabilitePei; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class PeiCaracteristiquesUpdateFactoryTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final ApiEndpoints apiEndpoints = new ApiEndpoints(); + + @Test + public void shouldBuildPibiUpdateFromV3Fields() { + TracabilitePei traca = new TracabilitePei(); + traca.setTypeObjet("PIBI"); + traca.setDiametreCanalisation("200"); + + Map data = new HashMap<>(); + data.put("pibiJumele", "PEI-002"); + data.put("dispositifInviolabilite", true); + data.put("renversable", false); + data.put("anneeFabrication", 2010); + data.put("typeReseauCode", "AEP"); + data.put("typeCanalisationCode", "FONTE"); + data.put("surpresse", true); + data.put("additive", false); + + PeiCaracteristiquesUpdate update = + PeiCaracteristiquesUpdateFactory.create( + mapper, apiEndpoints, PeiType.PIBI, "PEI-001", traca, data, "DN100", "BAYARD", "M1"); + + assertEquals("/deci/pei/PEI-001/pibi-caracteristiques", update.getPath()); + assertEquals("DN100", update.getPayload().get("codeDiametre").asText()); + assertEquals(200, update.getPayload().get("diametreCanalisation").asInt()); + assertEquals("PEI-002", update.getPayload().get("peiJumele").asText()); + assertEquals(true, update.getPayload().get("inviolabilite").asBoolean()); + assertEquals(false, update.getPayload().get("renversable").asBoolean()); + assertEquals("BAYARD", update.getPayload().get("codeMarque").asText()); + assertEquals("M1", update.getPayload().get("codeModele").asText()); + assertEquals(2010, update.getPayload().get("anneeFabrication").asInt()); + assertEquals("AEP", update.getPayload().get("codeTypeReseau").asText()); + assertEquals("FONTE", update.getPayload().get("codeTypeCanalisation").asText()); + assertEquals(true, update.getPayload().get("reseauSurpresse").asBoolean()); + assertEquals(false, update.getPayload().get("reseauAdditive").asBoolean()); + } + + @Test + public void shouldBuildPenaUpdateFromV3Fields() { + TracabilitePei traca = new TracabilitePei(); + traca.setTypeObjet("PENA"); + + Map data = new HashMap<>(); + data.put("capaciteIllimitee", true); + data.put("capaciteIncertaine", false); + data.put("capacite", 120); + data.put("quantiteAppoint", 3.5d); + data.put("materiauCode", "BETON"); + data.put("equipeHBE", true); + + PeiCaracteristiquesUpdate update = + PeiCaracteristiquesUpdateFactory.create( + mapper, apiEndpoints, PeiType.PENA, "PEI-003", traca, data, null, null, null); + + assertEquals("/deci/pei/PEI-003/pena-caracteristiques", update.getPath()); + assertEquals(true, update.getPayload().get("capaciteIllimitee").asBoolean()); + assertEquals(false, update.getPayload().get("capaciteIncertaine").asBoolean()); + assertEquals(120, update.getPayload().get("capacite").asInt()); + assertEquals(3.5d, update.getPayload().get("quantiteAppoint").asDouble(), 0.001d); + assertEquals("BETON", update.getPayload().get("codeMateriau").asText()); + assertEquals(true, update.getPayload().get("equipeHBE").asBoolean()); + } + + @Test + public void shouldFallbackToLegacyFields() { + TracabilitePei traca = new TracabilitePei(); + traca.setTypeObjet("PIBI"); + traca.setDiametreCanalisation("150"); + + Map data = new HashMap<>(); + data.put("jumelage", "PEI-LEGACY"); + data.put("inviolabilite", false); + data.put("renversable", true); + data.put("natureReseau", "AEP-LEGACY"); + data.put("natureCanalisation", "PVC"); + data.put("reseauSurpresse", false); + data.put("reseauAdditive", true); + + PeiCaracteristiquesUpdate update = + PeiCaracteristiquesUpdateFactory.create( + mapper, apiEndpoints, PeiType.PIBI, "PEI-004", traca, data, "DN80", "M", "X"); + + assertEquals("/deci/pei/PEI-004/pibi-caracteristiques", update.getPath()); + assertEquals("PEI-LEGACY", update.getPayload().get("peiJumele").asText()); + assertEquals(false, update.getPayload().get("inviolabilite").asBoolean()); + assertEquals(true, update.getPayload().get("renversable").asBoolean()); + assertEquals("AEP-LEGACY", update.getPayload().get("codeTypeReseau").asText()); + assertEquals("PVC", update.getPayload().get("codeTypeCanalisation").asText()); + assertEquals(false, update.getPayload().get("reseauSurpresse").asBoolean()); + assertEquals(true, update.getPayload().get("reseauAdditive").asBoolean()); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/PeiTypeResolverTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/PeiTypeResolverTest.java new file mode 100644 index 0000000..6532e4f --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/PeiTypeResolverTest.java @@ -0,0 +1,55 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class PeiTypeResolverTest { + + @Test + public void shouldResolvePibiUsingV3NatureId() { + Map pei = new HashMap<>(); + pei.put("peiNatureId", "nature-pibi-id"); + + List> pibiNatures = + Arrays.asList(nature("nature-pibi-id", "PI"), nature("other", "BI")); + List> penaNatures = Arrays.asList(nature("pena-id", "CITERNE")); + + assertEquals(PeiType.PIBI, PeiTypeResolver.resolve(pei, pibiNatures, penaNatures)); + } + + @Test + public void shouldResolvePenaUsingV3NatureId() { + Map pei = new HashMap<>(); + pei.put("peiNatureId", "pena-id"); + + List> pibiNatures = Arrays.asList(nature("pibi-id", "PI")); + List> penaNatures = Arrays.asList(nature("pena-id", "PA")); + + assertEquals(PeiType.PENA, PeiTypeResolver.resolve(pei, pibiNatures, penaNatures)); + } + + @Test + public void shouldReturnNullWhenNatureIsMissingOrUnknown() { + List> pibiNatures = Arrays.asList(nature("pibi-id", "PI")); + List> penaNatures = Arrays.asList(nature("pena-id", "PA")); + + assertNull(PeiTypeResolver.resolve(new HashMap<>(), pibiNatures, penaNatures)); + + Map pei = new HashMap<>(); + pei.put("peiNatureId", "unknown"); + assertNull(PeiTypeResolver.resolve(pei, pibiNatures, penaNatures)); + } + + private Map nature(String id, String code) { + Map nature = new HashMap<>(); + nature.put("natureId", id); + nature.put("natureCode", code); + return nature; + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/VisitPayloadTrackerTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/VisitPayloadTrackerTest.java new file mode 100644 index 0000000..713a2ff --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/VisitPayloadTrackerTest.java @@ -0,0 +1,26 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; + +public class VisitPayloadTrackerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void shouldAddLegacyContexteForTrackedPayload() { + ObjectNode payload = mapper.createObjectNode(); + payload.put("typeVisite", "CTP"); + payload.put("date", "2026-06-05 10:15"); + + ObjectNode trackedPayload = VisitPayloadTracker.toTrackedPayload(payload); + + assertFalse(payload.has("contexte")); + assertEquals("CTRL", trackedPayload.get("contexte").asText()); + assertEquals("CTP", trackedPayload.get("typeVisite").asText()); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/VisitTypeMapperTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/VisitTypeMapperTest.java new file mode 100644 index 0000000..72e84fa --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/message/VisitTypeMapperTest.java @@ -0,0 +1,32 @@ +package fr.eaudeparis.syncremocra.repository.message; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import org.junit.Test; + +public class VisitTypeMapperTest { + + @Test + public void shouldMapLocalVisitTypesToRemocraTypes() { + assertEquals("CTP", VisitTypeMapper.toRemocraType("CTRL")); + assertEquals("CTP", VisitTypeMapper.toRemocraType("PICF - CONTROLE")); + assertEquals("CTP", VisitTypeMapper.toRemocraType("PIQP TEST")); + assertEquals("NP", VisitTypeMapper.toRemocraType("NP")); + assertEquals("NP", VisitTypeMapper.toRemocraType("NPQP")); + } + + @Test + public void shouldRejectUnsupportedVisitTypes() { + assertNull(VisitTypeMapper.toRemocraType(null)); + assertNull(VisitTypeMapper.toRemocraType("RECEPTION")); + assertNull(VisitTypeMapper.toRemocraType("ROP")); + } + + @Test + public void shouldMapRemocraTypesBackToLegacyLocalTypes() { + assertEquals("CTRL", VisitTypeMapper.toLocalType("CTP")); + assertEquals("NP", VisitTypeMapper.toLocalType("NP")); + assertEquals("RECEPTION", VisitTypeMapper.toLocalType("RECEPTION")); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReferenceResolverTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReferenceResolverTest.java new file mode 100644 index 0000000..f621036 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/PeiNatureReferenceResolverTest.java @@ -0,0 +1,46 @@ +package fr.eaudeparis.syncremocra.repository.pei; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import fr.eaudeparis.syncremocra.repository.message.PeiType; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class PeiNatureReferenceResolverTest { + + @Test + public void shouldResolveV3PenaNatureId() { + Map pei = new HashMap<>(); + pei.put("peiNatureId", "pena-id"); + + PeiNatureReference reference = + PeiNatureReferenceResolver.resolve( + pei, Arrays.asList(nature("pibi-id", "PI")), Arrays.asList(nature("pena-id", "PA"))); + + assertEquals(PeiType.PENA, reference.getPeiType()); + assertEquals("PA", reference.getNatureCode()); + } + + @Test + public void shouldReturnNullWhenNatureCannotBeResolved() { + List> pibiNatures = Arrays.asList(nature("pibi-id", "PI")); + List> penaNatures = Arrays.asList(nature("pena-id", "PA")); + + assertNull(PeiNatureReferenceResolver.resolve(new HashMap<>(), pibiNatures, penaNatures)); + + Map pei = new HashMap<>(); + pei.put("peiNatureId", "unknown"); + assertNull(PeiNatureReferenceResolver.resolve(pei, pibiNatures, penaNatures)); + } + + private Map nature(String id, String code) { + Map nature = new HashMap<>(); + nature.put("natureId", id); + nature.put("natureCode", code); + return nature; + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/PeiRepositoryContractTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/PeiRepositoryContractTest.java new file mode 100644 index 0000000..22c4952 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/PeiRepositoryContractTest.java @@ -0,0 +1,164 @@ +package fr.eaudeparis.syncremocra.repository.pei; + +import static org.junit.Assert.assertEquals; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; +import fr.eaudeparis.syncremocra.api.ImmutableApiSettings; +import fr.eaudeparis.syncremocra.util.RequestManager; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Proxy; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Test; + +public class PeiRepositoryContractTest { + + private HttpServer server; + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + server = null; + } + } + + @Test + public void shouldResolveV3NatureAndFilterAnomaliesByVisitType() throws Exception { + AtomicInteger anomaliesCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/deci/pei/PEI-001", exchange -> respond(exchange, 200, "{\"peiNatureId\":\"pena-id\"}")); + server.createContext( + "/deci/referentiel/pibi/naturesPEI", + exchange -> respond(exchange, 200, "[{\"natureId\":\"pibi-id\",\"natureCode\":\"PI\"}]")); + server.createContext( + "/deci/referentiel/pena/naturesPEI", + exchange -> respond(exchange, 200, "[{\"natureId\":\"pena-id\",\"natureCode\":\"PA\"}]")); + server.createContext( + "/deci/referentiel/pena/PA/naturesAnomalies", + exchange -> { + anomaliesCalls.incrementAndGet(); + assertEquals("typeVisite=NP", exchange.getRequestURI().getRawQuery()); + respond( + exchange, + 200, + "[{\"anomalieCode\":\"AN1\",\"poidsAnomalieValIndispoTerrestre\":5," + + "\"listTypeVisite\":[\"NP\"]}," + + "{\"anomalieCode\":\"AN2\",\"poidsAnomalieValIndispoTerrestre\":3," + + "\"listTypeVisite\":[\"NP\",\"CTP\"]}," + + "{\"anomalieCode\":\"AN3\",\"poidsAnomalieValIndispoTerrestre\":5," + + "\"listTypeVisite\":[\"CTP\"]}]"); + }); + server.start(); + + PeiRepository repository = createRepository(); + + ArrayList bloquantes = repository.getNaturesAnomaliesAccessibles("PEI-001", "NP", true); + ArrayList allAccessible = + repository.getNaturesAnomaliesAccessibles("PEI-001", "NP", false); + + assertEquals(new ArrayList(Arrays.asList("AN1")), bloquantes); + assertEquals(new ArrayList(Arrays.asList("AN1", "AN2")), allAccessible); + assertEquals(2, anomaliesCalls.get()); + } + + @Test + public void shouldReturnEmptyWhenNatureCannotBeResolved() throws Exception { + AtomicInteger anomaliesCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/deci/pei/PEI-404", exchange -> respond(exchange, 200, "{\"peiNatureId\":\"unknown\"}")); + server.createContext( + "/deci/referentiel/pibi/naturesPEI", + exchange -> respond(exchange, 200, "[{\"natureId\":\"pibi-id\",\"natureCode\":\"PI\"}]")); + server.createContext( + "/deci/referentiel/pena/naturesPEI", + exchange -> respond(exchange, 200, "[{\"natureId\":\"pena-id\",\"natureCode\":\"PA\"}]")); + server.createContext( + "/deci/referentiel/pena/PA/naturesAnomalies", + exchange -> { + anomaliesCalls.incrementAndGet(); + respond(exchange, 200, "[]"); + }); + server.start(); + + PeiRepository repository = createRepository(); + + assertEquals( + new ArrayList(), repository.getNaturesAnomaliesAccessibles("PEI-404", "NP", false)); + assertEquals(0, anomaliesCalls.get()); + } + + private PeiRepository createRepository() { + PeiRepository repository = new PeiRepository(null); + repository.apiEndpoints = new ApiEndpoints(); + repository.requestManager = createRequestManager(repository.apiEndpoints); + return repository; + } + + private RequestManager createRequestManager(ApiEndpoints apiEndpoints) { + try { + Constructor constructor = null; + for (Constructor candidate : RequestManager.class.getDeclaredConstructors()) { + if (candidate.getParameterCount() == 3) { + constructor = candidate; + break; + } + } + if (constructor == null) { + throw new AssertionError("Test constructor not found on RequestManager"); + } + constructor.setAccessible(true); + Class reporterType = constructor.getParameterTypes()[2]; + Object reporter = + Proxy.newProxyInstance( + reporterType.getClassLoader(), + new Class[] {reporterType}, + (proxy, method, args) -> null); + return (RequestManager) + constructor.newInstance( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + apiEndpoints, + reporter); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Unable to instantiate RequestManager for contract test", e); + } + } + + private String serverBaseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] content = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(statusCode, content.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(content); + } + exchange.close(); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/RemocraAnomalieMapperTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/RemocraAnomalieMapperTest.java new file mode 100644 index 0000000..dfa1214 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pei/RemocraAnomalieMapperTest.java @@ -0,0 +1,44 @@ +package fr.eaudeparis.syncremocra.repository.pei; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class RemocraAnomalieMapperTest { + + @Test + public void shouldReadV3AnomalieFields() { + Map anomalie = new HashMap<>(); + anomalie.put("anomalieCode", "BSPP_PISD"); + anomalie.put("poidsAnomalieValIndispoTerrestre", 5); + anomalie.put("listTypeVisite", Arrays.asList("CTP", "NP")); + + assertEquals("BSPP_PISD", RemocraAnomalieMapper.getCode(anomalie)); + assertTrue(RemocraAnomalieMapper.isBloquante(anomalie)); + assertTrue(RemocraAnomalieMapper.supportsTypeVisite(anomalie, "CTP")); + assertFalse(RemocraAnomalieMapper.supportsTypeVisite(anomalie, "ROP")); + } + + @Test + public void shouldHandleNonBlockingAnomalies() { + Map anomalie = new HashMap<>(); + anomalie.put("anomalieCode", "BSPP_TEST"); + anomalie.put("poidsAnomalieValIndispoTerrestre", 2); + + assertFalse(RemocraAnomalieMapper.isBloquante(anomalie)); + } + + @Test + public void shouldAllowAnomaliesWithoutVisitTypeRestriction() { + Map anomalie = new HashMap<>(); + anomalie.put("anomalieCode", "BSPP_TEST"); + anomalie.put("poidsAnomalieValIndispoTerrestre", 5); + + assertTrue(RemocraAnomalieMapper.supportsTypeVisite(anomalie, "NP")); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantMapperTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantMapperTest.java new file mode 100644 index 0000000..c51aa35 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantMapperTest.java @@ -0,0 +1,70 @@ +package fr.eaudeparis.syncremocra.repository.pullmessage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import fr.eaudeparis.syncremocra.db.model.tables.pojos.PullHydrant; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class PullHydrantMapperTest { + + @Test + public void shouldMapV3HydrantFields() { + Map pei = new HashMap<>(); + pei.put("peiComplementAdresse", "Cour interieure"); + pei.put("peiDisponibiliteTerrestre", "AVAILABLE"); + pei.put("penaDisponibiliteHbe", "LIMITED"); + pei.put("peiNumeroVoie", "24"); + pei.put("peiSuffixeVoie", "TER"); + pei.put("peiNiveauId", "7f97c8a3-1111-2222-3333-444444444444"); + pei.put("peiVoieTexte", "Rue du Contrat"); + pei.put("peiCroisementId", "88888888-9999-aaaa-bbbb-cccccccccccc"); + pei.put("peiEnFace", false); + pei.put("peiDomaineId", "domaine-uuid"); + pei.put("peiCommuneId", "commune-uuid"); + pei.put("peiNatureId", "nature-uuid"); + pei.put("peiNatureDeciId", "nature-deci-uuid"); + pei.put("peiIndispoTemporaire", true); + pei.put("peiAnneeFabrication", "2004"); + + Map carac = new HashMap<>(); + carac.put("pibiDiametreId", "diametre-uuid"); + carac.put("pibiMarqueId", "marque-uuid"); + carac.put("pibiModeleId", "modele-uuid"); + carac.put("pibiDiametreCanalisation", "200"); + + PullHydrant hydrant = PullHydrantMapper.map(pei, carac); + + assertEquals("diametre-uuid", hydrant.getDiametre()); + assertEquals("marque-uuid", hydrant.getMarque()); + assertEquals("modele-uuid", hydrant.getModele()); + assertEquals(Integer.valueOf(200), hydrant.getDiametreCanalisation()); + assertEquals("2004", hydrant.getAnneeFabrication()); + assertEquals("Cour interieure", hydrant.getComplement()); + assertEquals("AVAILABLE", hydrant.getDispoTerrestre()); + assertEquals("LIMITED", hydrant.getDispoHbe()); + assertEquals(Integer.valueOf(24), hydrant.getNumeroVoie()); + assertEquals("TER", hydrant.getSuffixeVoie()); + assertEquals("7f97c8a3-1111-2222-3333-444444444444", hydrant.getNiveau()); + assertEquals("Rue du Contrat", hydrant.getVoie()); + assertEquals("88888888-9999-aaaa-bbbb-cccccccccccc", hydrant.getVoie2()); + assertEquals(Boolean.FALSE, hydrant.getEnFace()); + assertEquals("domaine-uuid", hydrant.getDomaine()); + assertEquals("commune-uuid", hydrant.getCommune()); + assertEquals("nature-uuid", hydrant.getNature()); + assertEquals("nature-deci-uuid", hydrant.getNatureDeci()); + assertEquals(Boolean.TRUE, hydrant.getIndispoTemporaire()); + } + + @Test + public void shouldIgnoreNonNumericStreetNumbers() { + Map pei = new HashMap<>(); + pei.put("peiNumeroVoie", "12B"); + + PullHydrant hydrant = PullHydrantMapper.map(pei, new HashMap<>()); + + assertNull(hydrant.getNumeroVoie()); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantVisiteMapperTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantVisiteMapperTest.java new file mode 100644 index 0000000..1178a69 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/PullHydrantVisiteMapperTest.java @@ -0,0 +1,46 @@ +package fr.eaudeparis.syncremocra.repository.pullmessage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +public class PullHydrantVisiteMapperTest { + + @Test + public void shouldResolveVisitIdentifierFromV3Contract() { + Map v3Visite = new HashMap<>(); + v3Visite.put("visiteId", "uuid-1"); + + assertEquals("uuid-1", PullHydrantVisiteMapper.getVisiteId(v3Visite)); + } + + @Test + public void shouldResolveVisitTypeAndMeasurementsFromBothContracts() { + Map v3Visite = new HashMap<>(); + v3Visite.put("typeVisite", "CTP"); + v3Visite.put("pressionDynamique", 1.5d); + v3Visite.put("pressionDynamiqueDebitMax", 0.8d); + + assertEquals("CTRL", PullHydrantVisiteMapper.getLocalVisitType(v3Visite)); + assertEquals( + Double.valueOf(1.5d), + PullHydrantVisiteMapper.getDouble(v3Visite, "pressionDyn", "pressionDynamique")); + assertEquals( + Double.valueOf(0.8d), + PullHydrantVisiteMapper.getDouble(v3Visite, "pressionDynDeb", "pressionDynamiqueDebitMax")); + } + + @Test + public void shouldResolveVisitDatesFromV3Contract() { + Map v3Visite = new HashMap<>(); + v3Visite.put("moment", "2026-06-05T09:30:00+02:00"); + + assertEquals( + LocalDateTime.of(2026, 6, 5, 9, 30), PullHydrantVisiteMapper.getVisitDate(v3Visite)); + assertNull(PullHydrantVisiteMapper.getVisitDate(new HashMap<>())); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/model/PeiDiffModelTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/model/PeiDiffModelTest.java new file mode 100644 index 0000000..b09f71b --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/repository/pullmessage/model/PeiDiffModelTest.java @@ -0,0 +1,61 @@ +package fr.eaudeparis.syncremocra.repository.pullmessage.model; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; + +public class PeiDiffModelTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void shouldDeserializeV3PeiDiffAsCaracteristiques() throws Exception { + String json = + "{" + + "\"peiId\":\"11111111-1111-1111-1111-111111111111\"," + + "\"numeroComplet\":\"PEI-001\"," + + "\"momentModification\":\"2026-06-05T08:15:00Z\"," + + "\"auteurModification\":\"EAU_DE_PARIS\"," + + "\"auteur\":{\"typeSourceModification\":\"API\"}," + + "\"typeOperation\":\"UPDATE\"," + + "\"typeObjet\":\"PEI\"" + + "}"; + + PeiDiffModel model = objectMapper.readValue(json, PeiDiffModel.class); + + assertEquals("PEI-001", model.getNumero()); + assertEquals("UPDATE", model.getOperation()); + assertEquals("CARACTERISTIQUES", model.getType()); + assertEquals("API", model.getAuteurModificationFlag()); + assertEquals("EAU_DE_PARIS", model.getOrganismeModification()); + assertTrue(model.getDateModification().getTime() > 0); + assertTrue(model.isModifiedByCurrentOrganisme("EAU_DE_PARIS")); + assertFalse(model.isModifiedByCurrentOrganisme("AUTRE_ORGANISME")); + } + + @Test + public void shouldDeserializeV3VisiteDiffAsVisites() throws Exception { + String json = + "{" + + "\"peiId\":\"22222222-2222-2222-2222-222222222222\"," + + "\"numeroComplet\":\"PEI-002\"," + + "\"momentModification\":\"2026-06-05T09:30:00Z\"," + + "\"auteurModification\":\"Jean Dupont\"," + + "\"auteur\":{\"typeSourceModification\":\"REMOCRA_WEB\"}," + + "\"typeOperation\":\"CREATE\"," + + "\"typeObjet\":\"VISITE\"" + + "}"; + + PeiDiffModel model = objectMapper.readValue(json, PeiDiffModel.class); + + assertEquals("PEI-002", model.getNumero()); + assertEquals("CREATE", model.getOperation()); + assertEquals("VISITES", model.getType()); + assertEquals("USER", model.getAuteurModificationFlag()); + assertEquals("Jean Dupont", model.getUtilisateurModification()); + assertFalse(model.isModifiedByCurrentOrganisme("EAU_DE_PARIS")); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraIndispoContractTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraIndispoContractTest.java new file mode 100644 index 0000000..5c94b34 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraIndispoContractTest.java @@ -0,0 +1,183 @@ +package fr.eaudeparis.syncremocra.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; +import fr.eaudeparis.syncremocra.api.ImmutableApiSettings; +import fr.eaudeparis.syncremocra.repository.message.IndispoTemporaireMapper; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Test; + +public class RemocraIndispoContractTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final ApiEndpoints apiEndpoints = new ApiEndpoints(); + private HttpServer server; + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + server = null; + } + } + + @Test + public void shouldSendCreateIndispoUsingV3Payload() throws Exception { + AtomicInteger postCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/deci/indispoTemporaire", + exchange -> { + if ("POST".equals(exchange.getRequestMethod())) { + postCalls.incrementAndGet(); + assertEquals("Bearer kc-token", exchange.getRequestHeaders().getFirst("Authorization")); + + String body = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + Map payload = + mapper.readValue(body, new TypeReference>() {}); + + assertEquals("Mise en indisponibilité Eau de Paris", payload.get("motif")); + assertEquals(Boolean.TRUE, payload.get("mailAvantIndisponibilite")); + assertEquals(Boolean.TRUE, payload.get("mailApresIndisponibilite")); + assertEquals(Boolean.TRUE, payload.get("basculeAutoDisponible")); + assertEquals(Boolean.TRUE, payload.get("basculeAutoIndisponible")); + assertTrue(String.valueOf(payload.get("dateDebut")).startsWith("2026-06-05T08:30:00")); + assertEquals(List.of("PEI-001"), payload.get("listeNumeroPei")); + + respond(exchange, 201, ""); + return; + } + respond(exchange, 405, ""); + }); + server.start(); + + ObjectNode payload = + IndispoTemporaireMapper.buildCreatePayload( + mapper, "PEI-001", LocalDateTime.of(2026, 6, 5, 8, 30)); + + RequestManager requestManager = createRequestManager(); + Integer responseCode = + requestManager.sendRequest("POST", apiEndpoints.indispoTemporaire(), payload.toString()); + + assertEquals(Integer.valueOf(201), responseCode); + assertEquals(1, postCalls.get()); + } + + @Test + public void shouldReadAndCloseActiveIndispoUsingV3Contract() throws Exception { + AtomicInteger getCalls = new AtomicInteger(); + AtomicInteger putCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/deci/indispoTemporaire", + exchange -> { + if ("GET".equals(exchange.getRequestMethod())) { + getCalls.incrementAndGet(); + assertEquals("organismeApi=EAU_DE_PARIS", exchange.getRequestURI().getRawQuery()); + respond( + exchange, + 200, + "[{\"indisponibiliteTemporaireId\":\"uuid-1\"," + + "\"indisponibiliteTemporaireDateDebut\":\"2026-06-05T08:30:00+02:00\"," + + "\"listeNumeroPei\":[\"PEI-001\"]}]"); + return; + } + respond(exchange, 405, ""); + }); + server.createContext( + "/deci/indispoTemporaire/uuid-1", + exchange -> { + putCalls.incrementAndGet(); + assertEquals("PUT", exchange.getRequestMethod()); + + String body = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + Map payload = + mapper.readValue(body, new TypeReference>() {}); + + assertEquals("2026-06-05T08:30:00+02:00", payload.get("dateDebut")); + assertTrue(String.valueOf(payload.get("dateFin")).startsWith("2026-06-05T12:45:00")); + assertEquals(List.of("PEI-001"), payload.get("listeNumeroPei")); + + respond(exchange, 200, ""); + }); + server.start(); + + RequestManager requestManager = createRequestManager(); + String response = + requestManager.sendGetRequest( + apiEndpoints.indispoTemporaire(), IndispoTemporaireMapper.buildSearchParams()); + List> indispos = + mapper.readValue(response, new TypeReference>>() {}); + + Map activeIndispo = + IndispoTemporaireMapper.findActiveIndispo(indispos, "PEI-001"); + assertEquals("uuid-1", String.valueOf(IndispoTemporaireMapper.getIndispoId(activeIndispo))); + assertNull(IndispoTemporaireMapper.findActiveIndispo(indispos, "PEI-999")); + + ObjectNode payload = + IndispoTemporaireMapper.buildUpdatePayload( + mapper, "PEI-001", activeIndispo, LocalDateTime.of(2026, 6, 5, 12, 45)); + Integer responseCode = + requestManager.sendRequest( + "PUT", + apiEndpoints.indispoTemporaire(IndispoTemporaireMapper.getIndispoId(activeIndispo)), + payload.toString()); + + assertEquals(Integer.valueOf(200), responseCode); + assertEquals(1, getCalls.get()); + assertEquals(1, putCalls.get()); + } + + private RequestManager createRequestManager() { + return new RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + apiEndpoints, + (codeErreur, message, idMessage) -> {}); + } + + private String serverBaseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] content = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(statusCode, content.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(content); + } + exchange.close(); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraPullPeiContractTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraPullPeiContractTest.java new file mode 100644 index 0000000..657eae2 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraPullPeiContractTest.java @@ -0,0 +1,172 @@ +package fr.eaudeparis.syncremocra.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; +import fr.eaudeparis.syncremocra.api.ImmutableApiSettings; +import fr.eaudeparis.syncremocra.db.model.tables.pojos.PullHydrant; +import fr.eaudeparis.syncremocra.repository.pullmessage.PullHydrantMapper; +import fr.eaudeparis.syncremocra.repository.pullmessage.model.PeiDiffModel; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Test; + +public class RemocraPullPeiContractTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final ApiEndpoints apiEndpoints = new ApiEndpoints(); + private HttpServer server; + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + server = null; + } + } + + @Test + public void shouldReadDiffThenFetchPeiAndCaracteristiquesUsingV3Contract() throws Exception { + AtomicInteger diffCalls = new AtomicInteger(); + AtomicInteger peiCalls = new AtomicInteger(); + AtomicInteger caracCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/deci/pei/diff", + exchange -> { + diffCalls.incrementAndGet(); + assertEquals("moment=2026-06-05%2008%3A00%3A00", exchange.getRequestURI().getRawQuery()); + respond( + exchange, + 200, + "[{\"numeroComplet\":\"PEI-001\"," + + "\"momentModification\":\"2026-06-05T08:15:00Z\"," + + "\"auteurModification\":\"SDIS\"," + + "\"auteur\":{\"typeSourceModification\":\"API\"}," + + "\"typeOperation\":\"UPDATE\"," + + "\"typeObjet\":\"PEI\"}]"); + }); + server.createContext( + "/deci/pei/PEI-001", + exchange -> { + peiCalls.incrementAndGet(); + respond( + exchange, + 200, + "{" + + "\"peiComplementAdresse\":\"Cour interieure\"," + + "\"peiDisponibiliteTerrestre\":\"AVAILABLE\"," + + "\"penaDisponibiliteHbe\":\"LIMITED\"," + + "\"peiNumeroVoie\":\"24\"," + + "\"peiSuffixeVoie\":\"TER\"," + + "\"peiNiveauId\":\"level-uuid\"," + + "\"peiVoieTexte\":\"Rue du Contrat\"," + + "\"peiCroisementId\":\"crossroad-uuid\"," + + "\"peiEnFace\":false," + + "\"peiDomaineId\":\"domaine-uuid\"," + + "\"peiCommuneId\":\"commune-uuid\"," + + "\"peiNatureId\":\"nature-uuid\"," + + "\"peiNatureDeciId\":\"nature-deci-uuid\"," + + "\"peiIndispoTemporaire\":true," + + "\"peiAnneeFabrication\":\"2004\"" + + "}"); + }); + server.createContext( + "/deci/pei/PEI-001/caracteristiques", + exchange -> { + caracCalls.incrementAndGet(); + respond( + exchange, + 200, + "{" + + "\"pibiDiametreId\":\"diametre-uuid\"," + + "\"pibiMarqueId\":\"marque-uuid\"," + + "\"pibiModeleId\":\"modele-uuid\"," + + "\"pibiDiametreCanalisation\":\"200\"" + + "}"); + }); + server.start(); + + RequestManager requestManager = createRequestManager(); + String diffJson = + requestManager.sendGetRequest( + apiEndpoints.peiDiff(), Map.of("moment", "2026-06-05 08:00:00")); + + List diffs = + mapper.readValue(diffJson, new TypeReference>() {}); + assertEquals(1, diffs.size()); + assertEquals("PEI-001", diffs.get(0).getNumero()); + assertEquals("CARACTERISTIQUES", diffs.get(0).getType()); + assertEquals("UPDATE", diffs.get(0).getOperation()); + assertTrue(diffs.get(0).getDateModification().getTime() > 0); + + Map pei = + mapper.readValue( + requestManager.sendGetRequest(apiEndpoints.pei("PEI-001")), + new TypeReference>() {}); + Map carac = + mapper.readValue( + requestManager.sendGetRequest(apiEndpoints.peiCaracteristiques("PEI-001")), + new TypeReference>() {}); + + PullHydrant hydrant = PullHydrantMapper.map(pei, carac); + + assertEquals("diametre-uuid", hydrant.getDiametre()); + assertEquals("marque-uuid", hydrant.getMarque()); + assertEquals("modele-uuid", hydrant.getModele()); + assertEquals(Integer.valueOf(200), hydrant.getDiametreCanalisation()); + assertEquals("Cour interieure", hydrant.getComplement()); + assertEquals("AVAILABLE", hydrant.getDispoTerrestre()); + assertEquals("LIMITED", hydrant.getDispoHbe()); + assertEquals(Integer.valueOf(24), hydrant.getNumeroVoie()); + assertEquals("TER", hydrant.getSuffixeVoie()); + assertEquals("Rue du Contrat", hydrant.getVoie()); + assertEquals("nature-uuid", hydrant.getNature()); + assertEquals(Boolean.TRUE, hydrant.getIndispoTemporaire()); + assertEquals(1, diffCalls.get()); + assertEquals(1, peiCalls.get()); + assertEquals(1, caracCalls.get()); + } + + private RequestManager createRequestManager() { + return new RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + apiEndpoints, + (codeErreur, message, idMessage) -> {}); + } + + private String serverBaseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] content = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(statusCode, content.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(content); + } + exchange.close(); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraVisitesContractTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraVisitesContractTest.java new file mode 100644 index 0000000..7da7810 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/util/RemocraVisitesContractTest.java @@ -0,0 +1,175 @@ +package fr.eaudeparis.syncremocra.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; +import fr.eaudeparis.syncremocra.api.ImmutableApiSettings; +import fr.eaudeparis.syncremocra.repository.message.VisitPayloadTracker; +import fr.eaudeparis.syncremocra.repository.message.VisitTypeMapper; +import fr.eaudeparis.syncremocra.repository.pullmessage.PullHydrantVisiteMapper; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Test; + +public class RemocraVisitesContractTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private final ApiEndpoints apiEndpoints = new ApiEndpoints(); + private HttpServer server; + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + server = null; + } + } + + @Test + public void shouldSendVisitCreationUsingV3Payload() throws Exception { + AtomicInteger authCalls = new AtomicInteger(); + AtomicInteger visitCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> { + authCalls.incrementAndGet(); + respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}"); + }); + server.createContext( + "/deci/pei/PEI-001/visites", + exchange -> { + visitCalls.incrementAndGet(); + assertEquals("POST", exchange.getRequestMethod()); + assertEquals("Bearer kc-token", exchange.getRequestHeaders().getFirst("Authorization")); + + String body = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + Map payload = + mapper.readValue(body, new TypeReference>() {}); + + assertEquals("CTP", payload.get("typeVisite")); + assertEquals("Eau de Paris", payload.get("agent1")); + assertEquals("2026-06-05 10:15", payload.get("date")); + assertEquals(80, payload.get("debit")); + assertEquals( + 1.7d, Double.valueOf(String.valueOf(payload.get("pressionDynamique"))), 0.0d); + assertFalse(payload.containsKey("contexte")); + + respond(exchange, 201, ""); + }); + server.start(); + + ObjectNode payload = mapper.createObjectNode(); + payload.put("typeVisite", VisitTypeMapper.toRemocraType("CTRL")); + payload.put("date", "2026-06-05 10:15"); + payload.put("agent1", "Eau de Paris"); + payload.put("debit", 80); + payload.put("pressionDynamique", 1.7d); + + RequestManager requestManager = createRequestManager(); + Integer codeRetour = + requestManager.sendRequest("POST", apiEndpoints.peiVisites("PEI-001"), payload.toString()); + + assertEquals(Integer.valueOf(201), codeRetour); + assertEquals(1, authCalls.get()); + assertEquals(1, visitCalls.get()); + } + + @Test + public void shouldReadVisitsUsingV3Contract() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/deci/pei/PEI-001/visites", + exchange -> + respond( + exchange, + 200, + "[{\"visiteId\":\"uuid-1\",\"moment\":\"2026-06-05T09:30:00+02:00\"," + + "\"typeVisite\":\"CTP\",\"anomalies\":[\"BSPP_APSE\"]}]")); + server.createContext( + "/deci/pei/PEI-001/visites/uuid-1", + exchange -> + respond( + exchange, + 200, + "{\"visiteId\":\"uuid-1\",\"moment\":\"2026-06-05T09:30:00+02:00\"," + + "\"typeVisite\":\"CTP\",\"agent1\":\"Eau de Paris\",\"debit\":60," + + "\"pression\":2.1,\"pressionDynamique\":1.4," + + "\"pressionDynamiqueDebitMax\":0.9,\"anomaliesConstatees\":[\"BSPP_APSE\"]}")); + server.start(); + + RequestManager requestManager = createRequestManager(); + + List> visites = + mapper.readValue( + requestManager.sendGetRequest(apiEndpoints.peiVisites("PEI-001")), + new TypeReference>>() {}); + assertEquals("uuid-1", String.valueOf(PullHydrantVisiteMapper.getVisiteId(visites.get(0)))); + + Map visiteDetail = + mapper.readValue( + requestManager.sendGetRequest(apiEndpoints.peiVisite("PEI-001", "uuid-1")), + new TypeReference>() {}); + + assertEquals("CTRL", PullHydrantVisiteMapper.getLocalVisitType(visiteDetail)); + assertEquals( + LocalDateTime.of(2026, 6, 5, 9, 30), PullHydrantVisiteMapper.getVisitDate(visiteDetail)); + assertEquals( + Double.valueOf(1.4d), + PullHydrantVisiteMapper.getDouble(visiteDetail, "pressionDyn", "pressionDynamique")); + assertEquals( + Double.valueOf(0.9d), + PullHydrantVisiteMapper.getDouble( + visiteDetail, "pressionDynDeb", "pressionDynamiqueDebitMax")); + assertTrue( + VisitPayloadTracker.toTrackedPayload( + mapper.createObjectNode().put("typeVisite", "CTP").put("date", "2026-06-05 09:30")) + .has("contexte")); + } + + private RequestManager createRequestManager() { + return new RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + apiEndpoints, + (codeErreur, message, idMessage) -> {}); + } + + private String serverBaseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] content = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(statusCode, content.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(content); + } + exchange.close(); + } +} diff --git a/app/src/test/java/fr/eaudeparis/syncremocra/util/RequestManagerTest.java b/app/src/test/java/fr/eaudeparis/syncremocra/util/RequestManagerTest.java new file mode 100644 index 0000000..487ee30 --- /dev/null +++ b/app/src/test/java/fr/eaudeparis/syncremocra/util/RequestManagerTest.java @@ -0,0 +1,373 @@ +package fr.eaudeparis.syncremocra.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import fr.eaudeparis.syncremocra.api.ApiEndpoints; +import fr.eaudeparis.syncremocra.api.ImmutableApiSettings; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.Test; + +public class RequestManagerTest { + + private HttpServer server; + + @After + public void tearDown() { + if (server != null) { + server.stop(0); + server = null; + } + } + + @Test + public void shouldUseKeycloakAndBasePathForGetRequest() throws Exception { + AtomicInteger tokenCalls = new AtomicInteger(); + AtomicInteger apiCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> { + tokenCalls.incrementAndGet(); + assertEquals("POST", exchange.getRequestMethod()); + respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}"); + }); + server.createContext( + "/remocra/deci/pei", + exchange -> { + apiCalls.incrementAndGet(); + assertEquals("GET", exchange.getRequestMethod()); + assertEquals("Bearer kc-token", exchange.getRequestHeaders().getFirst("Authorization")); + assertEquals("numero=PEI%201&statut=EN%20COURS", exchange.getRequestURI().getRawQuery()); + respond(exchange, 200, "{\"ok\":true}"); + }); + server.start(); + + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .basePath("/remocra") + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> {}); + + Map params = new LinkedHashMap<>(); + params.put("numero", "PEI 1"); + params.put("statut", "EN COURS"); + + String response = requestManager.sendGetRequest("/deci/pei", params); + + assertEquals("{\"ok\":true}", response); + assertEquals(1, tokenCalls.get()); + assertEquals(1, apiCalls.get()); + } + + @Test + public void shouldUseKeycloakAndCacheBearerToken() throws Exception { + AtomicInteger tokenCalls = new AtomicInteger(); + AtomicInteger apiCalls = new AtomicInteger(); + + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> { + tokenCalls.incrementAndGet(); + assertEquals("POST", exchange.getRequestMethod()); + String body = + new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + assertEquals( + "grant_type=client_credentials&client_id=sync-remocra&client_secret=top-secret", + body); + respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}"); + }); + server.createContext( + "/api/deci/pei", + exchange -> { + apiCalls.incrementAndGet(); + assertEquals("Bearer kc-token", exchange.getRequestHeaders().getFirst("Authorization")); + respond(exchange, 200, "{\"ok\":true}"); + }); + server.start(); + + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .basePath("/api") + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .mail("unused@example.com") + .password("unused") + .build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> {}); + + assertEquals("{\"ok\":true}", requestManager.sendGetRequest("/deci/pei")); + assertEquals("{\"ok\":true}", requestManager.sendGetRequest("/deci/pei")); + assertEquals(1, tokenCalls.get()); + assertEquals(2, apiCalls.get()); + } + + @Test + public void shouldRaiseConnectionErrorWhenApiIsUnavailable() { + List reportedErrors = new ArrayList<>(); + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder() + .host("http://127.0.0.1:1") + .authType("keycloak") + .keycloakUrl("http://127.0.0.1:1") + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> + reportedErrors.add(new ReportedError(codeErreur, message, idMessage))); + + boolean thrown = false; + try { + requestManager.sendGetRequest("/deci/pei"); + } catch (fr.eaudeparis.syncremocra.util.APIConnectionException e) { + thrown = true; + } catch (Exception e) { + throw new AssertionError("Unexpected exception", e); + } + + assertTrue(thrown); + assertEquals(1, reportedErrors.size()); + assertEquals("0003", reportedErrors.get(0).codeErreur); + assertEquals( + "Impossible d'établir une connexion avec l'API Remocra", reportedErrors.get(0).message); + assertEquals(null, reportedErrors.get(0).idMessage); + } + + @Test + public void shouldRaiseAuthenticationErrorWhenKeycloakConfigIsIncomplete() { + List reportedErrors = new ArrayList<>(); + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder() + .host("http://localhost") + .authType("keycloak") + .mail("unused@example.com") + .password("unused") + .build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> + reportedErrors.add(new ReportedError(codeErreur, message, idMessage))); + + boolean thrown = false; + try { + requestManager.sendGetRequest("/deci/pei"); + } catch (fr.eaudeparis.syncremocra.util.APIAuthentException e) { + thrown = true; + } catch (Exception e) { + throw new AssertionError("Unexpected exception", e); + } + + assertTrue(thrown); + assertEquals(1, reportedErrors.size()); + assertEquals("0200", reportedErrors.get(0).codeErreur); + assertEquals("Authentification refusée à l'API Remocra", reportedErrors.get(0).message); + assertEquals(null, reportedErrors.get(0).idMessage); + } + + @Test + public void shouldRaiseAuthenticationErrorWhenAuthTypeIsNotKeycloak() { + List reportedErrors = new ArrayList<>(); + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder().host("http://localhost").authType("legacy-jwt").build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> + reportedErrors.add(new ReportedError(codeErreur, message, idMessage))); + + boolean thrown = false; + try { + requestManager.sendGetRequest("/deci/pei"); + } catch (fr.eaudeparis.syncremocra.util.APIAuthentException e) { + thrown = true; + } catch (Exception e) { + throw new AssertionError("Unexpected exception", e); + } + + assertTrue(thrown); + assertEquals(1, reportedErrors.size()); + assertEquals("0200", reportedErrors.get(0).codeErreur); + assertEquals("Authentification refusée à l'API Remocra", reportedErrors.get(0).message); + assertEquals(null, reportedErrors.get(0).idMessage); + } + + @Test + public void shouldExposeHttpContextWhenGetRequestReturnsUnexpectedErrorBody() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/remocra/deci/pei/751080001", + exchange -> respond(exchange, 502, "Bad gateway")); + server.start(); + + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .basePath("/remocra") + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> {}); + + try { + requestManager.sendGetRequest("/deci/pei/751080001"); + } catch (fr.eaudeparis.syncremocra.util.RequestException e) { + assertEquals(502, e.getCode()); + assertNull(e.getCodeErreur()); + assertEquals( + "HTTP 502 lors de l'appel GET /deci/pei/751080001. Reponse: Bad gateway", + e.getMessage()); + return; + } + + throw new AssertionError("Expected RequestException"); + } + + @Test + public void shouldRaiseAuthenticationErrorWhenApiReturnsUnauthorized() throws Exception { + List reportedErrors = new ArrayList<>(); + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext("/remocra/deci/pei/751190852", exchange -> respond(exchange, 401, "")); + server.start(); + + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .basePath("/remocra") + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> + reportedErrors.add(new ReportedError(codeErreur, message, idMessage))); + + boolean thrown = false; + try { + requestManager.sendGetRequest("/deci/pei/751190852"); + } catch (fr.eaudeparis.syncremocra.util.APIAuthentException e) { + thrown = true; + } + + assertTrue(thrown); + assertEquals(1, reportedErrors.size()); + assertEquals("0200", reportedErrors.get(0).codeErreur); + assertEquals("Authentification refusée à l'API Remocra", reportedErrors.get(0).message); + assertEquals(null, reportedErrors.get(0).idMessage); + } + + @Test + public void shouldExposeMarqueModeleDiagnosticWhenPibiCaracteristiquesReturnsKnown500() + throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext( + "/realms/remocra/protocol/openid-connect/token", + exchange -> respond(exchange, 200, "{\"access_token\":\"kc-token\",\"expires_in\":300}")); + server.createContext( + "/remocra/deci/pei/751010252/pibi-caracteristiques", + exchange -> + respond(exchange, 500, "Collection contains no element matching the predicate.")); + server.start(); + + fr.eaudeparis.syncremocra.util.RequestManager requestManager = + new fr.eaudeparis.syncremocra.util.RequestManager( + ImmutableApiSettings.builder() + .host(serverBaseUrl()) + .basePath("/remocra") + .authType("keycloak") + .keycloakUrl(serverBaseUrl()) + .keycloakRealm("remocra") + .keycloakClientId("sync-remocra") + .keycloakClientSecret("top-secret") + .build(), + new ApiEndpoints(), + (codeErreur, message, idMessage) -> {}); + + try { + requestManager.sendRequest( + "PUT", + "/deci/pei/751010252/pibi-caracteristiques", + "{\"codeMarque\":\"PONT A MOUSSON\",\"codeModele\":\"BI PAM\"}"); + } catch (fr.eaudeparis.syncremocra.util.RequestException e) { + assertEquals(500, e.getCode()); + assertNull(e.getCodeErreur()); + assertEquals( + "HTTP 500 lors de l'appel PUT /deci/pei/751010252/pibi-caracteristiques. Reponse: " + + "Collection contains no element matching the predicate. Diagnostic: verifier le " + + "couple codeMarque/codeModele transmis au referentiel REMOcRA " + + "(codeMarque=PONT A MOUSSON, codeModele=BI PAM).", + e.getMessage()); + return; + } + + throw new AssertionError("Expected RequestException"); + } + + private static final class ReportedError { + private final String codeErreur; + private final String message; + private final Long idMessage; + + private ReportedError(String codeErreur, String message, Long idMessage) { + this.codeErreur = codeErreur; + this.message = message; + this.idMessage = idMessage; + } + } + + private String serverBaseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private void respond(HttpExchange exchange, int statusCode, String body) throws IOException { + byte[] content = body.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(statusCode, content.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(content); + } + exchange.close(); + } +} diff --git a/db/src/main/resources/db/migration/V1.39__ajout_motifs_indispo_ssq_projet.sql b/db/src/main/resources/db/migration/V1.39__ajout_motifs_indispo_ssq_projet.sql new file mode 100644 index 0000000..5befaa0 --- /dev/null +++ b/db/src/main/resources/db/migration/V1.39__ajout_motifs_indispo_ssq_projet.sql @@ -0,0 +1,8 @@ +-- One mapping per EDP motif prevents duplicate attention points on synchronization. +DELETE FROM edp.referentiel_anomalies +WHERE code_edp IN ('FERME DEMANDE SSQ', 'APP PROJET'); + +INSERT INTO edp.referentiel_anomalies (code_bspp, code_edp) +VALUES + ('BSPP_APSE', 'FERME DEMANDE SSQ'), + ('BSPP_APSE', 'APP PROJET'); diff --git a/doc/src/docs/asciidoc/Manuel exploitation.adoc b/doc/src/docs/asciidoc/Manuel exploitation.adoc index 969081e..bb39b46 100644 --- a/doc/src/docs/asciidoc/Manuel exploitation.adoc +++ b/doc/src/docs/asciidoc/Manuel exploitation.adoc @@ -54,6 +54,42 @@ docker images | grep edp docker ps -a | grep edp ---- +==== Diagnostiquer un job bloqué + +Les jobs `changedatacapture`, `pushworker`, `notification` et les commandes de base de données +sont des traitements ponctuels : leur conteneur doit disparaître à la fin de l'exécution grâce à +l'option `--rm`. Un message indiquant qu'un conteneur est déjà en cours empêche donc tout nouveau +lancement du même job. + +Avant de l'arrêter, vérifier depuis quand il s'exécute, l'image utilisée et ses derniers journaux : + +[source,sh] +---- +# Remplacer par changedatacapture, pushworker, db-migrate, etc. +docker ps --filter name=edp-syncremocra- +docker logs --tail 100 edp-syncremocra- +---- + +Si les journaux montrent que le traitement est terminé, en erreur, ou ne progresse plus, arrêter +gracieusement le conteneur. Ne pas l'arrêter pendant une migration Flyway ou un envoi en cours sans +avoir vérifié les journaux et les messages concernés. + +[source,sh] +---- +docker stop edp-syncremocra- +docker ps -a --filter name=edp-syncremocra- +---- + +La seconde commande ne doit plus retourner de conteneur. Le job peut alors être relancé avec son +script `/var/syncremocra/run-.sh`. En cas de mise à jour, vérifier au préalable que le script +référence l'image attendue et non une ancienne image telle que `:main` : + +[source,sh] +---- +grep 'ghcr.io/jdev-org/sync-remocra:' /var/syncremocra/run-.sh +/var/syncremocra/run-.sh +---- + ==== Exécuter l'application Définition d'une fonction utilitaire pour la suite : @@ -67,6 +103,7 @@ run-syncremocra() { export EDP_VERSION=$(cat /var/syncremocra/common.env | grep EDP_VERSION | cut -c13-) # run docker run --network="host" --rm \ + --security-opt seccomp=unconfined \ --env-file /var/syncremocra/common.env \ --env LOG_FILENAME=syncremocra-db-validate.log \ --user 2000:2000 \ @@ -112,9 +149,110 @@ MAIL_PASSWORD= MAIL_FROM=edp+admin@atolcd.com MAIL_HOST=smtp-relay.hosting.priv.atolcd.com MAIL_PORT=25 +API_HOST=http://localhost:8881 +API_BASE_PATH= +API_AUTH_TYPE=keycloak +API_KEYCLOAK_URL=https://keycloak.example +API_KEYCLOAK_REALM=remocra +API_KEYCLOAK_CLIENT_ID=sync-remocra +API_KEYCLOAK_CLIENT_SECRET=change-me ---- - le fichier `/var/syncremocra/config/syncremocra.conf` pour les autres. +L'application récupère un jeton OAuth2 par le flux `client_credentials` auprès de +`API_KEYCLOAK_URL`. Les paramètres `API_KEYCLOAK_REALM`, `API_KEYCLOAK_CLIENT_ID` et +`API_KEYCLOAK_CLIENT_SECRET` sont obligatoires. + +=== Exploitation avec l'API REMOcRA V3 + +Tous les jobs applicatifs qui appellent REMOcRA doivent utiliser la même image compatible V3 et +le même fichier `/var/syncremocra/common.env`. Cela concerne au minimum `pushworker` et, lorsqu'il +est exécuté comme job applicatif, `notification`. Une image ou une configuration d'authentification +restée sur l'ancienne version provoque des erreurs d'authentification, y compris si le push-worker +fonctionne correctement. + +Avant un déploiement ou un diagnostic, vérifier que les scripts de lancement utilisent le même tag +d'image et que les variables `API_*` sont présentes pour chaque conteneur. + +=== Chaîne de synchronisation V3 + +La synchronisation locale vers REMOcRA s'appuie sur trois jobs distincts : + +* `changedatacapture` lit `VUE_PEI_EDP_REMOCRA` et + `VUE_PEI_MOTIF_INDISPO_EDP_REMOCRA`, stocke un état dans les tables de traçabilité, puis crée les + messages à traiter ; +* `pushworker` construit les payloads et appelle l'API REMOcRA ; +* `notification` envoie les notifications liées aux erreurs de synchronisation. Il initialise aussi + le client API REMOcRA et doit donc disposer de la configuration OAuth2 V3. + +Les principaux types de messages créés par le changedatacapture sont : + +* `SPECIFIQUE` pour les dates de visite `date_maj_qp`, `date_maj_cf` et `date_maj_np` ; +* `MANUELLE` pour `date_maj_mj` ; +* `CARACTERISTIQUES` pour `date_maj_p` ; +* `INDISPO_TEMPORAIRE` pour `date_maj_e`. + +=== Contrat technique des visites V3 + +Le push-worker envoie les visites vers `POST /deci/pei/{reference}/visites`. Le payload API utilise +le champ `typeVisite` : + +* un libellé local commençant par `NP` est envoyé avec `typeVisite: NP` ; +* un libellé local commençant par `PICF`, `PIQP`, `CTRL` ou `CTP` est envoyé avec + `typeVisite: CTP` ; +* `CDP` n'est pas une valeur envoyée à l'API V3 ; c'est un libellé métier historique ; +* les visites `PIQP` transmettent `pression`, `pressionDynamique` et `debit` lorsqu'au moins une + mesure est disponible ; le même enrichissement est appliqué aux visites manuelles. + +Les anomalies sont envoyées dans `anomaliesConstatees` et `anomaliesControlees`. Dans +l'implémentation actuelle, les libellés contenant `CONTROLE REALISE` ou `EN SERVICE` contrôlent +toutes les anomalies accessibles pour le PEI et le type de visite. Les règles métier de levée des +points d'attention doivent rester validées dans le référentiel fonctionnel REMOcRA V3. + +=== Indisponibilités temporaires V3 + +Un changement de `date_maj_e` crée un message `INDISPO_TEMPORAIRE`. Le push-worker consulte +`/deci/indispoTemporaire` avec `organismeApi=EAU_DE_PARIS` avant de créer ou de clôturer une +indisponibilité temporaire. + +La création d'une indisponibilité temporaire REMOcRA est limitée aux motifs contenant `ARRET EAU` +ou `RENOUVEL`. Les autres motifs EDP peuvent néanmoins provoquer l'envoi d'une visite NP distincte +afin d'ajouter ou de lever les anomalies issues de la table locale `referentiel_anomalies`. + +Les motifs d'indisponibilité configurés sont `APP A RENOUVELER`, `APP A REPARER`, +`APP SANS EAU`, `APP SIGNALE`, `FERME DEMANDE TIERS`, `SANS CONDUITE`, `ARRET EAU`, `PUISAGE`, +`FERMETURE HIVERNALE`, `APP SUR CHANTIER`, `INACCESSIBLE`, `STREETPOOLING`, +`FERME DEMANDE SSQ` et `APP PROJET`. La correspondance complète et le comportement particulier +de `ARRET EAU` sont documentés dans le +link:conception/visites-v3.html[document de conception du traitement des visites]. + +Ce flux est distinct de la visite `SPECIFIQUE` ou `MANUELLE`. Lorsqu'une visite et un changement +d'état sont détectés à la même minute, deux appels de création de visite peuvent donc être produits. +En cas d'erreur REMOcRA `2118` indiquant qu'une visite existe déjà à la même date et heure, vérifier +les deux messages et leurs payloads avant tout rejeu. + +=== Diagnostic d'un cas de recette + +Pour attribuer un écart, suivre l'ordre suivant : + +. Vérifier les valeurs des vues Oracle, notamment `type_derniere_visite`, `etat`, les dates + `date_maj_*`, les mesures et les motifs d'indisponibilité. +. Vérifier qu'un message a été créé dans `edp.message`. +. Vérifier les colonnes `json`, `json_creation_visite`, `statut` et `erreur` du message. +. Vérifier le résultat REMOcRA uniquement lorsque le payload a été envoyé sans erreur HTTP. + +Exemple de requête : + +[source,sql] +---- +SELECT m.id, m.type, m.statut, m.date, m.erreur, m.json, m.json_creation_visite, + t.reference, t.etat, t.date_maj_e, t.type_derniere_visite +FROM edp.message m +JOIN edp.tracabilite_pei t ON t.id = m.id_traca_pei +WHERE t.reference = '' +ORDER BY m.date, m.id; +---- + **Configuration spécifique à un contexte** Elle est réalisée avec le passage de variables d'environnement spécifiques au conteneur. Par exemple dans le fichier `/var/syncremocra/run<-contexte>.sh`. diff --git a/doc/src/docs/asciidoc/Manuel installation.adoc b/doc/src/docs/asciidoc/Manuel installation.adoc index 32b6f0a..ef9a354 100644 --- a/doc/src/docs/asciidoc/Manuel installation.adoc +++ b/doc/src/docs/asciidoc/Manuel installation.adoc @@ -33,7 +33,14 @@ Deux options se présentent : avec registre ou sans registre. === Installation avec registre -*Les variables `EDP_REGISTRY_SERVER`, `EDP_REGISTRY_USERNAME` et `EDP_REGISTRY_PASSWORD` doivent être définies et exportée avant la procédure*. Elles sont nécessaires pour se connecter au registry Docker et on ne souhaite pas versionner les credentials (sécurité). Le registre est utilisé uniquement lorsque la variable `EDP_REGISTRY_SERVER` est définie. Par défaut la version `latest` est utilisée. +*Les variables `EDP_REGISTRY_SERVER`, `EDP_REGISTRY_USERNAME` et `EDP_REGISTRY_PASSWORD` doivent être définies et exportée avant la procédure*. Elles sont nécessaires pour se connecter au registry Docker et on ne souhaite pas versionner les credentials (sécurité). Le registre est utilisé uniquement lorsque la variable `EDP_REGISTRY_SERVER` est définie. + +Les images publiées suivent désormais deux règles : + +* un tag immuable par build pour éviter tout écrasement (`main-` pour les builds de `main`, `1.43` pour une release `v1.43`) ; +* un alias flottant éventuel pour faciliter certains usages (`main` pour la branche `main`, `latest` pour la dernière release). + +En exploitation, il est recommandé de configurer `EDP_VERSION` avec un tag immuable plutôt qu'avec `latest`. Après vérification, suivre les instructions du script `dist/install-ubuntu-20.04.sh` ou le déposer et l'exécuter : [source,sh] @@ -44,8 +51,8 @@ export EDP_REGISTRY_SERVER="client-docker-registry.atolcd.com" export EDP_REGISTRY_USERNAME="le-nom-de-l-utilisateur" export EDP_REGISTRY_PASSWORD="********" -# Exemple de version -export EDP_VERSION=1.0-SNAPSHOT +# Exemple de version immuable de release +export EDP_VERSION=1.43 # Démarrage de l'installation (exemple) chmod +x /root/install-ubuntu-20.04.sh @@ -61,8 +68,8 @@ Vérifier par exemple avec la commande : `/var/syncremocra/run-db-validate.sh` Après vérification, suivre les instructions du script `dist/install-ubuntu-20.04.sh` ou le déposer et l'exécuter : [source,sh] ---- -# Exemple de version -export EDP_VERSION=1.0-SNAPSHOT +# Exemple de version immuable +export EDP_VERSION=main-a1b2c3d4e5f6 # Exemple d'emplacement de fichier contenant les images docker export EDP_DOCKER_IMAGES_TAR="/root/edp-syncremocra-docker-images-${EDP_VERSION}.tar" @@ -103,6 +110,18 @@ nano /var/syncremocra/config/syncremocra.conf # docker pull client-docker-registry.atolcd.com/edp/syncremocra ---- +Vérifier en particulier la configuration d'accès à l'API REMOcRA : + +- `API_HOST` et `API_BASE_PATH` +- `API_AUTH_TYPE=keycloak` +- `API_KEYCLOAK_URL`, `API_KEYCLOAK_REALM`, `API_KEYCLOAK_CLIENT_ID` et + `API_KEYCLOAK_CLIENT_SECRET` + +Pour une installation REMOcRA V3, la même image applicative et le même fichier `common.env` doivent +être utilisés par les scripts `run-changedatacapture.sh`, `run-pushworker.sh` et par le script qui +exécute la commande applicative `notification`. Le job de notification initialise le client API +REMOcRA ; il doit donc recevoir les variables `API_*` et ne doit pas rester sur une image antérieure. + Reconfiguration de l'accès à Oracle depuis PostgreSQL (remplacer le XXXXXXXXXX par la bonne valeur) [source,sh] diff --git a/doc/src/docs/asciidoc/Manuel mise a jour.adoc b/doc/src/docs/asciidoc/Manuel mise a jour.adoc index 53c1e7f..d9f1335 100644 --- a/doc/src/docs/asciidoc/Manuel mise a jour.adoc +++ b/doc/src/docs/asciidoc/Manuel mise a jour.adoc @@ -25,7 +25,7 @@ On met à jour la version dans la configuration : [source,sh] ---- # Exemple de version -export EDP_VERSION=1.0 +export EDP_VERSION=1.43 # Nouvelle version dans le fichier de configuration sed -i "s/EDP_VERSION.*=.*/EDP_VERSION=${EDP_VERSION}/g" /var/syncremocra/common.env @@ -46,6 +46,38 @@ docker pull ${APP_IMAGE_FULL} docker image prune -f ---- +Bonnes pratiques de versionnement Docker : + +* utiliser un tag immuable pour `EDP_VERSION` ; +* pour une release Git `v1.43`, utiliser `EDP_VERSION=1.43` ; +* pour un build de la branche `main`, utiliser un tag de type `main-` ; +* éviter d'utiliser `latest` en production, car cet alias peut changer sans modification de configuration locale. + +=== Vérifications spécifiques à REMOcRA V3 + +La mise à jour V3 ne doit pas être limitée au seul script du push-worker. Vérifier que tous les +scripts exécutant l'application utilisent la même image et le même tag, en particulier : + +* `run-changedatacapture.sh` ; +* `run-pushworker.sh` ; +* le script exécutant la commande applicative `notification`, s'il est utilisé. + +Ces conteneurs doivent tous charger `/var/syncremocra/common.env`, qui contient la configuration +OAuth2/Keycloak de REMOcRA V3. Mettre à jour uniquement le push-worker peut laisser le job de +notification sur une image ou une configuration ancienne et provoquer des notifications +d'authentification erronées. + +Après mise à jour, exécuter une fois chaque job et vérifier son journal avant de réactiver les +tâches planifiées : + +[source,sh] +---- +/var/syncremocra/run-changedatacapture.sh +/var/syncremocra/run-pushworker.sh +# Si ce script existe dans l'installation : +/var/syncremocra/run-notification.sh +---- + Lorsqu'aucun registre n'est utilisé, on charge les nouvelles images, en ayant déposé le fichier tar au préalable : Par exemple : diff --git a/doc/src/docs/asciidoc/conception/pushworker/caracteristiques-v3.adoc b/doc/src/docs/asciidoc/conception/pushworker/caracteristiques-v3.adoc new file mode 100644 index 0000000..ca3406b --- /dev/null +++ b/doc/src/docs/asciidoc/conception/pushworker/caracteristiques-v3.adoc @@ -0,0 +1,145 @@ += Conception - Mise à jour des caractéristiques REMOcRA V3 +Eau de Paris - Synchronisation Remocra +v${project.version}, ${timestamp} + +:toc: +:toclevels: 3 + +* link:../../index.html[Accueil] +* link:../regles-metier.html[Conception - vue d'ensemble] +* link:../visites-v3.html[Conception - traitement des visites REMOcRA V3] + +== Objet et périmètre + +Ce document décrit le comportement *actuellement implémenté* pour les messages +`CARACTERISTIQUES`. Il couvre les mises à jour des caractéristiques PIBI et PENA +envoyées par le push-worker à REMOcRA V3. + +Le changedatacapture crée un message `CARACTERISTIQUES` lorsqu'une modification des données +de pression ou de débit est détectée dans les vues Oracle. Le push-worker détermine ensuite le +type réel du PEI depuis REMOcRA et appelle le service de caractéristiques correspondant. + +== Enchaînement commun + +Pour chaque message `CARACTERISTIQUES`, le push-worker : + +. charge la traçabilité locale du PEI ; +. lit dans REMOcRA le PEI puis ses caractéristiques actuelles : + `GET /deci/pei/{reference}` et `GET /deci/pei/{reference}/caracteristiques` ; +. lit les natures de PEI PIBI et PENA : + `GET /deci/referentiel/pibi/naturesPEI` et `GET /deci/referentiel/pena/naturesPEI` ; +. détermine si la nature du PEI REMOcRA est de type PIBI ou PENA ; +. construit le payload du type déterminé ; +. appelle l'un des services suivants par `PUT` : + `PUT /deci/pei/{reference}/pibi-caracteristiques` ou + `PUT /deci/pei/{reference}/pena-caracteristiques`. + +Le message est traité seulement si la réponse HTTP est `200` ou `201`. + +== Référentiels locaux utilisés + +Le traitement PIBI utilise deux correspondances stockées par Sync-remocra : + +|=== +|Donnée source |Table locale |Valeur envoyée + +|`diametre` +|`referentiel_hydrant_diametres`, recherche par `code_edp` +|`code_bspp`, envoyé comme `codeDiametre` + +|modèle EDP non vide +|`referentiel_marques_modeles`, recherche insensible à la casse par `code_modele_edp` +|`code_marque_remocra` et `code_modele_remocra` +|=== + +Une absence de diamètre dans le référentiel génère l'erreur interne `I1001`. +Une absence de marque/modèle pour un modèle EDP renseigné génère l'erreur interne `I1000`. +Une nature de PEI REMOcRA absente des référentiels PIBI et PENA génère `I1004`. + +== Cas PIBI + +=== Service appelé + +`PUT /deci/pei/{reference}/pibi-caracteristiques` + +=== Payload envoyé + +|=== +|Clé |Origine + +|`codeDiametre` +|Correspondance locale du diamètre EDP. + +|`diametreCanalisation` +|Valeur locale `diametre_canalisation`, convertie en entier, ou `null`. + +|`peiJumele` +|Caractéristique REMOcRA existante `jumelage`, ou ancien nom `pibiJumele`. + +|`inviolabilite` +|Caractéristique REMOcRA existante `inviolabilite`, ou ancien nom `dispositifInviolabilite`. + +|`renversable`, `anneeFabrication` +|Caractéristiques REMOcRA existantes. + +|`codeMarque`, `codeModele` +|Correspondance locale de la marque et du modèle EDP. Si aucun modèle EDP n'est renseigné, les valeurs sont `null`. + +|`codeTypeReseau` +|Caractéristique REMOcRA `natureReseau`, ou ancien nom `typeReseauCode`. + +|`codeTypeCanalisation` +|Caractéristique REMOcRA `natureCanalisation`, ou ancien nom `typeCanalisationCode`. + +|`reseauSurpresse` +|Caractéristique REMOcRA `reseauSurpresse`, ou ancien nom `surpresse`. + +|`reseauAdditive` +|Caractéristique REMOcRA `reseauAdditive`, ou ancien nom `additive`. +|=== + +Les mesures d'essai EDP ne sont pas placées dans le payload PIBI de caractéristiques. Elles sont +transmises par une visite `PIQP` ou `MANUELLE`, lorsque le changedatacapture crée le message de +visite correspondant. Voir link:../visites-v3.html[le traitement des visites]. + +== Cas PENA + +=== Service appelé + +`PUT /deci/pei/{reference}/pena-caracteristiques` + +=== Payload envoyé + +|=== +|Clé |Origine + +|`capaciteIllimitee` +|Caractéristique REMOcRA existante `illimite`, ou ancien nom `capaciteIllimitee`. + +|`capaciteIncertaine` +|Caractéristique REMOcRA existante `incertaine`, ou ancien nom `capaciteIncertaine`. + +|`capacite` +|Caractéristique REMOcRA existante. + +|`quantiteAppoint` +|Caractéristique REMOcRA existante `debitAppoint`, ou ancien nom `quantiteAppoint`. + +|`codeMateriau` +|Caractéristique REMOcRA existante `codeMateriau`, ou ancien nom `materiauCode`. + +|`equipeHBE` +|Caractéristique REMOcRA existante. +|=== + +Le payload PENA conserve donc les caractéristiques déjà présentes dans REMOcRA. Le code actuel +n'alimente pas ces champs avec des valeurs issues de la traçabilité EDP. + +== Limites fonctionnelles à connaître + +* Le push-worker ne déduit pas le type PIBI ou PENA à partir d'un libellé EDP : il utilise la + nature du PEI retournée par REMOcRA. +* Les correspondances diamètre, marque et modèle relèvent des tables locales de référentiel ; + elles doivent être maintenues si les codes EDP ou REMOcRA évoluent. +* Une erreur de référentiel, de lecture ou d'appel API applique le cycle de reprise décrit dans + link:traitement-messages.html[le traitement des messages]. diff --git a/doc/src/docs/asciidoc/conception/pushworker/traitement-messages.adoc b/doc/src/docs/asciidoc/conception/pushworker/traitement-messages.adoc new file mode 100644 index 0000000..480257d --- /dev/null +++ b/doc/src/docs/asciidoc/conception/pushworker/traitement-messages.adoc @@ -0,0 +1,142 @@ += Conception - Traitement des messages du push-worker +Eau de Paris - Synchronisation Remocra +v${project.version}, ${timestamp} + +:toc: +:toclevels: 3 + +* link:../../index.html[Accueil] +* link:../regles-metier.html[Conception - vue d'ensemble] +* link:../visites-v3.html[Conception - traitement des visites REMOcRA V3] +* link:caracteristiques-v3.html[Conception - caractéristiques REMOcRA V3] + +== Objet et périmètre + +Ce document décrit le cycle de traitement actuellement implémenté dans le push-worker : sélection +des messages, envoi API, statuts, reprises et contrôle différé des indisponibilités temporaires. + +== Messages sélectionnés + +Au démarrage, le push-worker sélectionne les messages `edp.message` : + +* au statut `A TRAITER` ; +* ou au statut `EN ERREUR` avec `synchroniser = true`. + +Ils sont traités par ordre croissant de date. Le paramètre +`FLAG_STATUS_PUSHWORKER` est positionné à `EN COURS` pendant le traitement, puis à `TERMINE` à la +fin du job. + +== Routage par type de message + +|=== +|Type |Traitement |Service REMOcRA + +|`SPECIFIQUE` +|Visite issue d'une donnée de visite de la vue. +|`POST /deci/pei/{reference}/visites` + +|`MANUELLE` +|Visite manuelle, notamment utilisée pour les essais. +|`POST /deci/pei/{reference}/visites` + +|`INDISPO_TEMPORAIRE` +|Création, mise à jour ou levée d'indisponibilité temporaire selon le motif et l'état du PEI. +|Service d'indisponibilité temporaire ou visite NP selon le cas. + +|`CARACTERISTIQUES` +|Mise à jour des caractéristiques selon la nature PIBI ou PENA du PEI dans REMOcRA. +|`PUT /deci/pei/{reference}/pibi-caracteristiques` ou `pena-caracteristiques` +|=== + +Les types de visites, les payloads et les règles de motifs sont détaillés dans +link:../visites-v3.html[le document de conception des visites]. Les caractéristiques sont détaillées +dans link:caracteristiques-v3.html[le document de conception des caractéristiques]. + +== Enregistrement du payload + +Avant l'appel HTTP, le payload est enregistré dans `edp.message.json`. + +Pour les messages `SPECIFIQUE` et `MANUELLE`, la copie locale contient également le champ +historique `contexte` (`NP` ou `CTRL`). Ce champ n'est pas transmis à l'API REMOcRA V3 : la requête +HTTP contient le payload de visite sans `contexte`. + +== Succès de l'appel API + +Une réponse HTTP `200` ou `201` marque le message comme traité : + +* `statut = TRAITE` pour les visites et caractéristiques ordinaires ; +* incrément de `synchronisations` ; +* `synchroniser = false`. + +Pour une action d'indisponibilité temporaire correctement envoyée, le statut est `A VERIFIER` au +lieu de `TRAITE`. Le push-worker renseigne alors `date_debut_verif` à environ cinq minutes et +trente secondes après l'envoi afin de laisser à REMOcRA le temps d'appliquer le traitement. + +== Vérification différée des indisponibilités temporaires + +Le job `notification` traite ensuite les messages `A VERIFIER` dont `date_debut_verif` est échue. +Il appelle `GET /deci/pei/{reference}` et compare : + +|=== +|Donnée locale |Valeur REMOcRA attendue + +|`edp.tracabilite_pei.etat = Disponible` +|`dispoTerrestre = DISPO` + +|`edp.tracabilite_pei.etat = Indisponible` +|`dispoTerrestre = INDISPO` +|=== + +Dans les deux cas, le message est ensuite positionné à `TRAITE`. En cas d'écart, le job de +notification envoie le courriel d'incohérence avant de clôturer le message. Ce contrôle est donc +réalisé par le job `notification`, et non directement par le push-worker. + +== Erreurs et reprises + +=== Erreur de construction, référentiel ou réponse API + +Pour une erreur applicative ou une réponse API en erreur, le message passe à `EN ERREUR` et son +compteur `synchronisations` est incrémenté. Le paramètre `synchroniser` est maintenu à `true` tant +que le nombre maximal de tentatives défini dans `edp.type_erreur` n'est pas atteint. + +Lorsque ce seuil est atteint, `synchroniser` est positionné à `false` et une erreur est créée pour +le job de notification. Le message n'est alors plus repris automatiquement. + +=== Erreur de connexion ou d'authentification + +Une erreur de connexion à l'API ou d'authentification arrête le traitement de la liste courante. +Le message courant et les messages restants sont marqués en erreur selon leur règle de reprise. +Les codes d'erreur utilisés pour la notification sont `0003` pour la connexion et `0200` pour +l'authentification ; après épuisement des tentatives, une erreur `I1003` est créée. + +== Interruption du push-worker + +Au démarrage, si `FLAG_STATUS_PUSHWORKER` vaut encore `EN COURS`, le job considère qu'une exécution +précédente s'est interrompue. Il bascule alors les messages qui étaient en cours de traitement vers +le cycle d'erreur et de reprise, plutôt que de lancer un second traitement concurrent. + +== Diagnostic minimal + +Pour identifier le comportement d'un PEI, consulter ses messages et leur payload : + +[source,sql] +---- +select + m.id, + m.type, + m.statut, + m.date, + m.synchronisations, + m.synchroniser, + m.erreur, + m.json, + m.json_creation_visite +from edp.message m +join edp.tracabilite_pei t on t.id = m.id_traca_pei +where t.reference = '' +order by m.date, m.id; +---- + +Un message absent signifie que le changedatacapture n'a pas créé de demande à transmettre. Un +message `TRAITE` confirme la réussite HTTP du push-worker, mais ne permet pas à lui seul de +confirmer l'application des règles métier internes à REMOcRA. diff --git a/doc/src/docs/asciidoc/conception/regles-metier.adoc b/doc/src/docs/asciidoc/conception/regles-metier.adoc new file mode 100644 index 0000000..b621719 --- /dev/null +++ b/doc/src/docs/asciidoc/conception/regles-metier.adoc @@ -0,0 +1,82 @@ += Règles métiers - Vue d'ensemble +Eau de Paris - Synchronisation Remocra +v${project.version}, ${timestamp} + +:toc: + +* link:../index.html[Accueil] + +== Objectif + +Sync-remocra synchronise les informations des points d'eau incendie (PEI) entre le système Eau de +Paris et REMOCRA. Il ne définit pas les règles métier REMOCRA : il transporte et transforme les +données selon le contrat d'API et les référentiels disponibles. + +== Rôles des systèmes + +|=== +|Système |Rôle + +|WatGIS / SmartMobility +|Saisie et gestion des PEI, visites, mesures, états et indisponibilités par Eau de Paris. + +|Vues Oracle EDP +|Préparent les données à synchroniser : état, dernière visite, dates de modification, mesures et +motifs d'indisponibilité actifs. + +|Sync-remocra +|Détecte les changements, conserve une traçabilité, construit les payloads de l'API et appelle +REMOCRA. + +|REMOCRA +|Enregistre les PEI, visites et indisponibilités temporaires ; applique son référentiel d'anomalies +et ses règles de disponibilité. +|=== + +== Circulation des informations + +[source,text] +---- +WatGIS / SmartMobility + | + v +Vues Oracle EDP + | + v +changedatacapture + | + v +Tables de traçabilité et messages Sync-remocra + | + v +pushworker + | + v +API REMOCRA V3 + | + v +REMOCRA +---- + +== Fonctionnement simplifié + +. Une information est créée ou modifiée dans WatGIS / SmartMobility : visite, mesure, + caractéristique du PEI, état disponible/indisponible ou motif d'indisponibilité. +. Les vues Oracle mettent cette information à disposition de Sync-remocra avec des dates de mise à + jour. +. Le job `changedatacapture` détecte les dates modifiées et crée un message local à traiter. +. Le job `pushworker` lit le message, récupère la traçabilité associée et envoie les données à + REMOCRA par l'API V3. +. REMOCRA interprète le payload reçu selon ses propres référentiels et règles métier. +. En cas d'erreur, Sync-remocra conserve l'erreur avec le message ; le job `notification` adresse + les notifications configurées. + +== Limites de responsabilité + +* Les vues Oracle déterminent les données sources exposées à Sync-remocra. +* Sync-remocra applique les transformations techniques et les correspondances présentes dans ses + référentiels locaux. +* REMOCRA détermine notamment les anomalies accessibles, le caractère bloquant d'un point + d'attention et l'état final du PEI. +* Les règles métier attendues doivent être documentées dans un référentiel fonctionnel partagé ; + elles ne doivent pas être déduites uniquement des résultats affichés dans REMOCRA. diff --git a/doc/src/docs/asciidoc/conception/visites-v3.adoc b/doc/src/docs/asciidoc/conception/visites-v3.adoc new file mode 100644 index 0000000..24e0508 --- /dev/null +++ b/doc/src/docs/asciidoc/conception/visites-v3.adoc @@ -0,0 +1,446 @@ += Conception - Traitement des visites REMOcRA V3 +Eau de Paris - Synchronisation Remocra +v${project.version}, ${timestamp} + +:toc: +:toclevels: 3 + +* link:../index.html[Accueil] +* link:regles-metier.html[Conception - vue d'ensemble] +* link:pushworker/caracteristiques-v3.html[Conception - caractéristiques REMOcRA V3] +* link:pushworker/traitement-messages.html[Conception - traitement des messages du push-worker] + +== Objet et périmètre + +Ce document décrit le comportement *actuellement implémenté* par le push-worker pour les visites. +Il ne remplace pas le référentiel métier REMOcRA. Une absence de point d'attention dans un payload +décrit ci-dessous signifie que Sync-remocra ne l'ajoute pas directement à partir de ce libellé. + +Le point de départ est le champ `type_derniere_visite` de `VUE_PEI_EDP_REMOCRA`, stocké dans +`edp.tracabilite_pei` par le changedatacapture. + +== Enchaînement commun d'une visite + +Pour un message `SPECIFIQUE` ou `MANUELLE`, le push-worker suit cet enchaînement : + +. Lit la traçabilité du PEI et son `type_derniere_visite`. +. Interroge REMOcRA afin de connaître le type et la nature du PEI, puis les anomalies accessibles : + `GET /deci/pei/{reference}`, `GET /deci/referentiel/pibi/naturesPEI`, + `GET /deci/referentiel/pena/naturesPEI` et + `GET /deci/referentiel/{type}/{nature}/naturesAnomalies?typeVisite={NP|CTP}`. +. Construit le payload ci-dessous. +. Appelle `POST /deci/pei/{reference}/visites`. + +Le payload réellement envoyé à l'API ne contient pas `contexte`. Ce champ historique est ajouté +uniquement à la copie locale enregistrée dans `edp.message.json` : `CTRL` pour `CTP` et `NP` pour +`NP`. + +== Paramètres possibles du payload de visite + +|=== +|Paramètre |Origine et comportement + +|`typeVisite` +|`NP` ou `CTP` uniquement. `CDP` n'est jamais envoyé à l'API V3. + +|`date` +|Format `yyyy-MM-dd HH:mm`. Pour un message `SPECIFIQUE`, valeur de `date_derniere_visite`. +Pour une visite `MANUELLE`, valeur de `date_essai`. Un `NPQP` traité comme visite manuelle conserve +`date_derniere_visite` lorsqu'il provient d'un message `SPECIFIQUE`. + +|`agent1` +|Toujours `Eau de Paris`. + +|`pression`, `pressionDynamique`, `debit` +|Ajoutés pour une visite `PIQP` et une visite manuelle lorsqu'au moins une mesure est présente. +Lorsque ce bloc est ajouté, les trois clés sont présentes ; une mesure source absente est envoyée à +`null`. + +|`anomaliesConstatees` +|Anomalies constatées par la visite. + +|`anomaliesControlees` +|Anomalies contrôlées par la visite. +|=== + +== Règles communes sur les anomalies + +Les règles suivantes s'appliquent à tous les préfixes de visite : + +* un libellé contenant `CONTROLE REALISE` ou `EN SERVICE` envoie toutes les anomalies accessibles + dans `anomaliesControlees` et un tableau `anomaliesConstatees` vide ; +* un libellé contenant `TRAVAUX` ou `RENOUVELER` ajoute `BSPP_APDP` dans les deux tableaux ; +* un libellé contenant `SANS EAU` ajoute `BSPP_APSE` dans les deux tableaux ; +* un libellé contenant `PB SIGNALETIQUE` ajoute `BSPP_PISD` et/ou `BSPP_BISD` seulement si ces + codes sont accessibles pour le PEI et le type de visite ; +* tout autre libellé envoie les deux tableaux vides. + +Le statut disponible ou indisponible du PEI ne modifie pas ce premier payload de visite. Le flux +séparé `INDISPO_TEMPORAIRE`, décrit plus loin, peut toutefois créer une seconde visite NP. + +== Visites de type NP + +Les types dont le libellé commence par `NP` produisent un message `SPECIFIQUE` lorsque +`date_maj_np` est modifiée. Le payload de base est : + +[source,json] +---- +{ + "typeVisite": "NP", + "date": "date_derniere_visite", + "agent1": "Eau de Paris", + "anomaliesConstatees": [], + "anomaliesControlees": [] +} +---- + +=== NP - BOUCHE INCENDIE A RENOUVELER + +Le libellé contient `RENOUVELER`. Le payload NP ajoute `BSPP_APDP` dans `anomaliesConstatees` et +`anomaliesControlees`. + +=== NP - BOUCHE INCENDIE APPAREIL DÉTERIORÉ + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE ASTREINTE + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE CONTROLE DE NUIT + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE DEPOSE OU EN TRAVAUX + +Le libellé contient `TRAVAUX`. Le payload NP ajoute `BSPP_APDP` dans les deux tableaux. + +=== NP - BOUCHE INCENDIE EN SERVICE + +Le payload NP ajoute toutes les anomalies accessibles dans `anomaliesControlees`. Aucune anomalie +n'est placée dans `anomaliesConstatees`. + +=== NP - BOUCHE INCENDIE EN SERVICE SUITE CHG PIE + +Le payload NP ajoute toutes les anomalies accessibles dans `anomaliesControlees`. Aucune anomalie +n'est placée dans `anomaliesConstatees`. + +=== NP - BOUCHE INCENDIE ENCOMBRE OU INACCESSIBLE + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE FUITE BAC + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE FUITE SUR PISSETTE + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE FUITE TERRE/FOURREAU/EGO + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE HORS SERVICE + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE PB DEBIT/PRESSION + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE PIECES A CHANGER + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE PRESSION INSUFFISANTE + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE PUISAGE SUR APPAREIL + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE RV PRISE HS + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - BOUCHE INCENDIE SITUATION A REVOIR + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - POINT EAU INCENDIE CONTROLE REALISE + +Le payload NP ajoute toutes les anomalies accessibles dans `anomaliesControlees`. Aucune anomalie +n'est placée dans `anomaliesConstatees`. + +=== NP - POINT EAU INCENDIE PB DEBIT/PRESSION + +Le payload NP conserve les deux tableaux d'anomalies vides. + +=== NP - POINT EAU INCENDIE PB SIGNALETIQUE + +Le payload NP ajoute `BSPP_PISD` et/ou `BSPP_BISD` dans les deux tableaux, selon les anomalies +accessibles retournées par REMOcRA. + +== Visites de type PICF + +Les types dont le libellé commence par `PICF` produisent un message `SPECIFIQUE` lorsque +`date_maj_cf` est modifiée. Sync-remocra les convertit en `typeVisite: CTP`. Le payload de base est : + +[source,json] +---- +{ + "typeVisite": "CTP", + "date": "date_derniere_visite", + "agent1": "Eau de Paris", + "anomaliesConstatees": [], + "anomaliesControlees": [] +} +---- + +=== PICF - POINT EAU INCENDIE APPAREIL DÉTERIORÉ + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE CONTROLE FONCTIONNEL + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE CONTROLE REALISE + +Le payload CTP ajoute toutes les anomalies accessibles dans `anomaliesControlees`. Aucune anomalie +n'est placée dans `anomaliesConstatees`. + +=== PICF - POINT EAU INCENDIE COUVERCLE MANQU/DETERIOR + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE DEGORGER COFFRE/COLONNE + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE DEPOSE OU EN TRAVAUX + +Le libellé contient `TRAVAUX`. Le payload CTP ajoute `BSPP_APDP` dans les deux tableaux. + +=== PICF - POINT EAU INCENDIE DIFFICILE A MANŒUVRER + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE EN ECOULEMENT + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE EN SERVICE + +Le payload CTP ajoute toutes les anomalies accessibles dans `anomaliesControlees`. Aucune anomalie +n'est placée dans `anomaliesConstatees`. + +=== PICF - POINT EAU INCENDIE ENCOMBRE OU INACCESSIBLE + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE PB DEBIT/PRESSION + +Le payload CTP conserve les deux tableaux d'anomalies vides. + +=== PICF - POINT EAU INCENDIE PB SIGNALETIQUE + +Le payload CTP ajoute `BSPP_PISD` et/ou `BSPP_BISD` dans les deux tableaux, selon les anomalies +accessibles retournées par REMOcRA. + +=== PICF - POINT EAU INCENDIE SANS EAU + +Le libellé contient `SANS EAU`. Le payload CTP ajoute `BSPP_APSE` dans les deux tableaux. + +== Visites de type PIQP + +Les types dont le libellé commence par `PIQP` produisent un message `SPECIFIQUE` lorsque +`date_maj_qp` est modifiée. Sync-remocra les convertit en `typeVisite: CTP` et ajoute les mesures +lorsqu'au moins une valeur est présente. Le payload de base est : + +[source,json] +---- +{ + "typeVisite": "CTP", + "date": "date_derniere_visite", + "agent1": "Eau de Paris", + "pression": "essai_pression_statique ou null", + "pressionDynamique": "essai_pression_dynamique ou null", + "debit": "essai_debit ou null", + "anomaliesConstatees": [], + "anomaliesControlees": [] +} +---- + +Les trois clés de mesure ne sont absentes que si les trois valeurs source sont absentes. + +=== PIQP - POINT EAU INCENDIE APPAREIL DÉTERIORÉ + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE CONTROLE Q/P + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE CONTROLE REALISE + +Le payload CTP avec mesures éventuelles ajoute toutes les anomalies accessibles dans +`anomaliesControlees`. Aucune anomalie n'est placée dans `anomaliesConstatees`. + +=== PIQP - POINT EAU INCENDIE COUVERCLE MANQU/DETERIOR + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE DEGORGER COFFRE/COLONNE + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE DEPOSE OU EN TRAVAUX + +Le libellé contient `TRAVAUX`. Le payload CTP avec mesures éventuelles ajoute `BSPP_APDP` dans les +deux tableaux. + +=== PIQP - POINT EAU INCENDIE DIFFICILE A MANŒUVRER + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE EN ECOULEMENT + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE ENCOMBRE OU INACCESSIBLE + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE FUITE BAC + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE PB DEBIT/PRESSION + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. + +=== PIQP - POINT EAU INCENDIE PB SIGNALETIQUE + +Le payload CTP avec mesures éventuelles ajoute `BSPP_PISD` et/ou `BSPP_BISD` dans les deux tableaux, +selon les anomalies accessibles retournées par REMOcRA. + +=== PIQP - POINT EAU INCENDIE PEI INTROUVABLE + +Le payload CTP avec mesures éventuelles conserve les deux tableaux d'anomalies vides. Le code ne +détecte pas `INTROUVABLE` et n'ajoute donc pas `BSPP_INTR` directement. + +=== PIQP - POINT EAU INCENDIE SANS EAU + +Le libellé contient `SANS EAU`. Le payload CTP avec mesures éventuelles ajoute `BSPP_APSE` dans les +deux tableaux. + +== Visites de type NPQP + +Un libellé commençant par `NPQP` reçu dans un message `SPECIFIQUE` est traité comme une visite +manuelle : `typeVisite: NP`, mesures ajoutées si disponibles, et `date` égale à +`date_derniere_visite`. Les règles communes d'anomalies s'appliquent au libellé complet. + +== Visites manuelles + +Un message `MANUELLE`, créé à partir de `date_maj_mj`, appelle le même endpoint de visite avec : + +[source,json] +---- +{ + "typeVisite": "NP", + "date": "date_essai", + "agent1": "Eau de Paris", + "pression": "essai_pression_statique ou null si le bloc de mesures est ajoute", + "pressionDynamique": "essai_pression_dynamique ou null si le bloc de mesures est ajoute", + "debit": "essai_debit ou null si le bloc de mesures est ajoute", + "anomaliesConstatees": "selon le type_derniere_visite", + "anomaliesControlees": "selon le type_derniere_visite" +} +---- + +Les règles communes d'anomalies s'appliquent au `type_derniere_visite` de la traçabilité, même si +le message est de type `MANUELLE`. + +== Type de visite non reconnu + +Si `type_derniere_visite` ne commence ni par `NP`, ni par `PICF`, `PIQP`, `CTRL` ou `CTP`, le +push-worker lève l'erreur interne `2001`. Aucun appel `POST /deci/pei/{reference}/visites` n'est +effectué. + +== Flux distinct d'indisponibilité temporaire + +Lorsque `date_maj_e` est modifiée, le changedatacapture crée un message `INDISPO_TEMPORAIRE`. Ce +flux est indépendant de la visite décrite dans les sections précédentes. + +=== PEI indisponible + +. Sync-remocra interroge `GET /deci/indispoTemporaire?organismeApi=EAU_DE_PARIS`. +. Si un motif contient `ARRET EAU` ou `RENOUVEL` et qu'aucune indisponibilité EDP n'est active, + il appelle `POST /deci/indispoTemporaire` avec le motif générique, `dateDebut`, les bascules + automatiques et `listeNumeroPei`. +. Si les motifs EDP actifs diffèrent des motifs mémorisés, Sync-remocra peut appeler une seconde fois + `POST /deci/pei/{reference}/visites` avec `typeVisite: NP`. Les motifs présents sont placés dans + `anomaliesConstatees` et `anomaliesControlees` après conversion par `referentiel_anomalies` ; les + motifs retirés sont placés seulement dans `anomaliesControlees`. +. Une modification portant uniquement sur `ARRET EAU` ne crée pas cette seconde visite. + +=== Correspondance des motifs d'indisponibilité EDP + +Le changedatacapture lit les motifs actifs dans `VUE_PEI_MOTIF_INDISPO_EDP_REMOCRA` et les +convertit par la table locale `edp.referentiel_anomalies`. Les motifs sont mis en majuscules avant +la recherche, mais le rapprochement reste exact : les accents, espaces et termes complémentaires +doivent correspondre à la valeur exposée par la vue. + +|=== +|Motif EDP |Point d'attention REMOcRA |Traitement additionnel + +|`APP A RENOUVELER` +|`BSPP_APDP` - Déposé ou en travaux +|Création d'une IT et visite NP de motifs. + +|`APP A REPARER` +|`BSPP_APDP` - Déposé ou en travaux +|Visite NP de motifs. + +|`APP SANS EAU`, `APP SIGNALE`, `FERME DEMANDE TIERS`, `SANS CONDUITE`, `PUISAGE`, +`FERMETURE HIVERNALE`, `FERME DEMANDE SSQ`, `APP PROJET` +|`BSPP_APSE` - Sans eau +|Visite NP de motifs. + +|`ARRET EAU` +|`BSPP_APSE` - Sans eau +|Création d'une IT. Si c'est le seul motif ajouté ou retiré, aucune visite NP de motifs n'est envoyée. + +|`APP SUR CHANTIER` +|`BSPP_APEN` - Inaccessible : dans emprise de chantier +|Visite NP de motifs. + +|`INACCESSIBLE` +|`BSPP_INTE` - Inaccessible : sous une terrasse (resto, café, ...) +|Visite NP de motifs. + +|`STREETPOOLING` +|`STRTPOOL` - Street pooling (indisponible, PEI fermé) +|Visite NP de motifs. +|=== + +Les valeurs `BSPP_*` doivent être configurées comme anomalies accessibles pour le type de PEI et +la visite NP dans REMOcRA. Le push-worker ne transmet pas directement l'état `Indispo` : REMOcRA +le détermine à partir de l'IT et du caractère bloquant de ces points d'attention. + +=== PEI disponible + +. Sync-remocra interroge `GET /deci/indispoTemporaire?organismeApi=EAU_DE_PARIS`. +. Si une indisponibilité temporaire EDP est active, il appelle + `PUT /deci/indispoTemporaire/{id}` avec `dateFin`. +. Si des motifs EDP sont mémorisés, il peut appeler une seconde fois + `POST /deci/pei/{reference}/visites` avec `typeVisite: NP`, un tableau + `anomaliesConstatees` vide et les codes mappés dans `anomaliesControlees`. +. Un motif mémorisé `ARRET EAU` seul ne crée pas cette seconde visite. + +Les visites directes et les visites générées par ce flux utilisent une date à la minute. Lorsqu'elles +ont la même date et heure, REMOcRA peut répondre par l'erreur `2118` indiquant qu'une visite existe +déjà. diff --git a/doc/src/docs/asciidoc/include/historique.adoc b/doc/src/docs/asciidoc/include/historique.adoc index 44c93e0..75f3723 100644 --- a/doc/src/docs/asciidoc/include/historique.adoc +++ b/doc/src/docs/asciidoc/include/historique.adoc @@ -6,4 +6,7 @@ |Version |Date |Modifications |0.1 |09/03/2021 |Version initiale |0.2 |23/03/2021 |Installation Ubuntu 20.04, mise à jour, exploitation +|0.3 |05/06/2026 |Adaptation technique à REMOcRA V3 : authentification Keycloak et gestion des versions des images Docker +|0.4 |01/09/2026 |Mise à jour des manuels REMOcRA V3 et ajout de la documentation de conception des règles métiers et du push-worker +|0.5 |04/09/2026 |Ajout des correspondances d'indisponibilité `FERME DEMANDE SSQ` et `APP PROJET` vers `BSPP_APSE`, et procédure de diagnostic des jobs Docker bloqués |======================================================================= diff --git a/doc/src/docs/asciidoc/index.adoc b/doc/src/docs/asciidoc/index.adoc index 1c47d92..7bc01cd 100644 --- a/doc/src/docs/asciidoc/index.adoc +++ b/doc/src/docs/asciidoc/index.adoc @@ -18,4 +18,15 @@ include::include/historique.adoc[] * link:Manuel%20installation.html[Manuel d'installation] * link:Manuel%20mise%20a%20jour.html[Manuel de mise à jour] -* link:Manuel%20exploitation.html[Manuel d'exploitation] \ No newline at end of file +* link:Manuel%20exploitation.html[Manuel d'exploitation] +* link:conception/regles-metier.html[Conception - vue d'ensemble] +* link:conception/visites-v3.html[Conception - traitement des visites REMOcRA V3] +* link:conception/pushworker/caracteristiques-v3.html[Conception - caractéristiques REMOcRA V3] +* link:conception/pushworker/traitement-messages.html[Conception - traitement des messages du push-worker] + +== REMOcRA V3 + +Les adaptations techniques REMOcRA V3, la configuration OAuth2/Keycloak, le traitement des visites +`NP` et `CTP`, les indisponibilités temporaires et la procédure de diagnostic sont documentés dans +le link:Manuel%20exploitation.html[Manuel d'exploitation]. Les étapes de déploiement homogène des +jobs sont décrites dans le link:Manuel%20mise%20a%20jour.html[Manuel de mise à jour]. diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index 0344aef..22c3da6 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -1,13 +1,13 @@ # -------------------- # Build artefacts # -------------------- -FROM maven:3-jdk-11 as app-builder +FROM maven:3-jdk-11 AS app-builder ARG APP_VERSION= WORKDIR /app USER root ENV HOME /var/maven -ENV MAVEN_CONFIG /var/maven/.m2 -ENV MAVEN_OPTS -Duser.home=/var/maven +ENV MAVEN_CONFIG=/var/maven/.m2 +ENV MAVEN_OPTS=-Duser.home=/var/maven COPY app /app/app COPY db /app/db COPY doc /app/doc @@ -17,11 +17,11 @@ RUN mvn -DnewVersion=${APP_VERSION} versions:set -DgenerateBackupPoms=true \ # -------------------- # Build runnable image # -------------------- -FROM openjdk:11-jre +FROM eclipse-temurin:11-jre RUN apt-get update -y && apt-get upgrade -y && apt-get install locales htop nano -y && rm -rf /var/lib/apt/lists/* -ENV LANG fr_FR.UTF-8 -ENV TZ Europe/Paris +ENV LANG=fr_FR.UTF-8 +ENV TZ=Europe/Paris RUN echo "fr_FR.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen fr_FR.UTF-8 @@ -34,8 +34,8 @@ COPY --from=app-builder /app/app/target/syncremocra-app*.jar lib/ # Non root user ARG UID=1000 ARG GID=1000 -RUN groupadd -g ${GID} edp \ - && useradd -u ${UID} -g edp edp \ +RUN groupadd -o -g ${GID} edp \ + && useradd -o -u ${UID} -g ${GID} edp \ && chown -R edp /app USER edp diff --git a/pom.xml b/pom.xml index 695c091..d1181a9 100644 --- a/pom.xml +++ b/pom.xml @@ -29,6 +29,7 @@ 1.18.1 1.6.2 2.12.2 + 0.8.12 2.9.0 @@ -192,6 +193,11 @@ flyway-maven-plugin ${flyway.version} + + org.jacoco + jacoco-maven-plugin + ${jacoco.version} +