From d3cf137f88d9d766c54bb83326de3c57ddacd95c Mon Sep 17 00:00:00 2001 From: florimondmanca Date: Tue, 9 Jul 2024 15:55:32 +0200 Subject: [PATCH] Integration Litteralis / MEL --- .env | 3 + assets/customElements/map.js | 2 +- config/packages/framework.yaml | 3 + config/packages/monolog.yaml | 6 + config/services.yaml | 9 + docs/adr/010_litteralis.md | 423 +++++++++++++++++ .../ImportLitteralisRegulationCommand.php | 21 + ...portLitteralisRegulationCommandHandler.php | 33 ++ .../Enum/RegulationOrderRecordSourceEnum.php | 1 + .../Litteralis/LitteralisExecutor.php | 61 +++ .../Litteralis/LitteralisExtractor.php | 113 +++++ .../Litteralis/LitteralisReporter.php | 99 ++++ .../Litteralis/LitteralisReporterFactory.php | 20 + .../Litteralis/LitteralisTransformer.php | 442 ++++++++++++++++++ .../Regulation/LocationRepository.php | 2 + .../Command/LitteralisImportCommand.php | 40 ++ 16 files changed, 1277 insertions(+), 1 deletion(-) create mode 100644 docs/adr/010_litteralis.md create mode 100644 src/Application/Litteralis/Command/ImportLitteralisRegulationCommand.php create mode 100644 src/Application/Litteralis/Command/ImportLitteralisRegulationCommandHandler.php create mode 100644 src/Infrastructure/Litteralis/LitteralisExecutor.php create mode 100644 src/Infrastructure/Litteralis/LitteralisExtractor.php create mode 100644 src/Infrastructure/Litteralis/LitteralisReporter.php create mode 100644 src/Infrastructure/Litteralis/LitteralisReporterFactory.php create mode 100644 src/Infrastructure/Litteralis/LitteralisTransformer.php create mode 100644 src/Infrastructure/Symfony/Command/LitteralisImportCommand.php diff --git a/.env b/.env index 591b1ca6c..ef4dca58b 100644 --- a/.env +++ b/.env @@ -21,6 +21,9 @@ APP_SECRET=abc APP_EUDONET_PARIS_BASE_URL=https://eudonet-partage.apps.paris.fr APP_BAC_IDF_DECREES_FILE=data/bac_idf/decrees.json APP_BAC_IDF_CITIES_FILE=data/bac_idf/cities.csv +APP_LITTERALIS_WFS_BASE_URL=https://apps.sogelink.fr +APP_LITTERALIS_WFS_USERNAME= +APP_LITTERALIS_WFS_PASSWORD= DATABASE_URL="postgresql://dialog:dialog@database:5432/dialog" REDIS_URL="redis://redis:6379" API_ADRESSE_BASE_URL=https://api-adresse.data.gouv.fr diff --git a/assets/customElements/map.js b/assets/customElements/map.js index c6e7bb467..ea0ff6d18 100644 --- a/assets/customElements/map.js +++ b/assets/customElements/map.js @@ -136,7 +136,7 @@ class MapLibreMap { // Use > 0 to avoid "blob effect" at low zoom levels. // Use a small enough value to enable details at bigger zoom levels. // NOTE: computation is performed on the client's CPU using the data loaded in memory. - tolerance: 3, + tolerance: 1, }); dataSource.onChange(data => { diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml index f325a2442..fd038d354 100644 --- a/config/packages/framework.yaml +++ b/config/packages/framework.yaml @@ -25,6 +25,9 @@ framework: base_uri: '%env(API_ADRESSE_BASE_URL)%' eudonet_paris.http.client: base_uri: '%env(APP_EUDONET_PARIS_BASE_URL)%' + litteralis.wfs.http.client: + base_uri: '%env(APP_LITTERALIS_WFS_BASE_URL)%' + auth_basic: '%env(APP_LITTERALIS_WFS_USERNAME)%:%env(APP_LITTERALIS_WFS_PASSWORD)%' when@test: framework: diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 5fb9f950f..105482334 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -23,6 +23,12 @@ monolog: path: "%kernel.project_dir%/log/jop/import.%kernel.environment%.log" formatter: monolog.formatter.json channels: ["jop_import"] + litteralis_import: + level: debug + type: rotating_file + path: "%kernel.project_dir%/log/litteralis/import.%kernel.environment%.log" + formatter: monolog.formatter.json + channels: ["litteralis_import"] when@dev: monolog: diff --git a/config/services.yaml b/config/services.yaml index a93fcd763..27baba002 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -114,6 +114,15 @@ services: tags: - { name: monolog.logger, channel: jop_import } + # -------------- + # Litteralis + # -------------- + + App\Infrastructure\Litteralis\LitteralisReporterFactory: + arguments: ['@logger'] + tags: + - { name: monolog.logger, channel: litteralis_import } + when@test: services: App\Tests\Mock\APIAdresseMockClient: diff --git a/docs/adr/010_litteralis.md b/docs/adr/010_litteralis.md new file mode 100644 index 000000000..3bfdeb8dd --- /dev/null +++ b/docs/adr/010_litteralis.md @@ -0,0 +1,423 @@ +# 010 - Intégration Litteralis + +* Date : 2024-07-11 +* Personnes impliquées : Florimond Manca (auteur principal), équipe DiaLog (relecture) +* Statut : BROUILLON + +**Table des matières** + +* [Contexte](#contexte) +* [Définitions](#définitions) +* [Source technique des données](#source-technique-des-données) + * [Structure de l'API](#structure-de-lapi) + * [Authentification](#authentification) + * [Couches d'intérêt](#couches-dintérêt) +* [Structure des données](#structure-des-données) + * [Contenu de la réponse de l'API](#contenu-de-la-réponse-de-lapi) + * [Correspondance des champs](#correspondance-des-champs) + * [Qualité des données source](#qualité-des-données-source) +* [Gestion des erreurs](#Gestion-des-erreurs) +* [Périmètre et volumes d'intégration](#périmètre-et-volumes-dintégration) +* [Modèle d'exécution](#modèle-dexécution) +* [Paramétrage](#paramétrage) +* [Références](#références) + +## Contexte + +Une partie de la stratégie de DiaLog est d'intégrer des données issues de solutions existantes, notamment dans le cas de métropoles déjà équipées. + +[Litteralis](https://www.sogelink.com/solution/litteralis/) est une solution de gestion réglementaire du domaine public routier éditée par Sogelink et utilisée par environ 1500 collectivités en France. + +Litteralis est notamment utilisé par la MEL (Métropole Européenne de Lille) avec qui une réunion en physique a eu lieu le 29/03/2024 (voir [pad CR](https://pad.incubateur.net/MaOMoW0QS82FIj5oAABVAw#2024-03-29---Pr%C3%A9paration-visite-Lille)). + +Cette ADR a pour objectif de documenter l'approche choisie pour intégrer les données de la MEL stockées dans Litteralis. + +## Définitions + +Les données Litteralis font intervenir 3 notions principales : + +* Arrêté +* Emprise +* Mesure + +Les notions d'arrêté et de mesure correspondent plutôt bien à celles de DiaLog (tables `RegulationOrder` et `Measure` de notre côté). + +La notion **d'emprise**, qui est centrale dans Litteralis, n'a pas d'équivalent strict chez nous. Dans Litteralis, une emprise fait partie d'un arrêté et traduit l'application d'une ou plusieurs mesures sur une ou plusieurs localisations. Chez nous, les localisations sont _contenues par une mesure_, il n'y a pas d'entité qui contiendrait les deux comme le fait une emprise. + +Ainsi, dans Litteralis : + +``` +Arrêté "1..N" -- "1..1" Emprise +Emprise "1..N" -- "1..1" Mesure +Emprise "1..N" -- "1..1" Localisation +``` + +Dans DiaLog : + +``` +Arrêté (`RegulationOrder`) "1..N" -- "1..1" Mesure (`Measure`) +Mesure (`Measure`) "1..N" -- "1..1" Localisation (`Location`) +``` + +Cette différence de structure ne pose pas vraiment de problème. Pour chaque emprise Litteralis, on créera une mesure DiaLog contenant les localisations de l'emprise. + +## Source technique des données + +Les données Litteralis peuvent être récupérées via une **API HTTP**. + +### Structure de l'API + +L'API Litteralis utilise le standard [WFS](https://www.ogc.org/standard/wfs) (Web Feature Service). + +URL de base : https://apps.sogelink.fr/maplink/public/wfs?SERVICE=wfs&VERSION=2&REQUEST=GetFeature + +D'autres paramètres sont à ajouter : + +* `TYPENAME=...` (obligatoire) : définit la couche dont on veut récupérer les données (voir [Couches d'intérêt](#couches-dintérêt)) +* `cql_filter` : permet de filtrer les données avec la syntaxe [ECQL](https://docs.geoserver.org/latest/en/user/filter/ecql_reference.html#filter-ecql-reference) +* `outputFormat=application/json` : permet de récupérer du GeoJSON (par défaut l'API renvoie du XML) + +La réponse est **paginée**. On peut le voir à ces champs présents dans la réponse GeoJSON : + +```json + "totalFeatures": 16635, + "numberMatched": 16635, + "numberReturned": 1000, +``` + +Pour parcourir l'ensemble des données, on exécutera plusieurs requêtes en utilisant les paramètres WFS `count` et `startIndex`. + +Par exemple pour traiter l'ensemble des points de données par groupe de 1000 : + +* Requête 1 : `count=1000` et `startIndex=0` + * On détermine le nombre total de requêtes à faire en calculant `(numberMatched // numberReturned) + 1` (dans l'exemple ci-dessus on ferait donc 17 requêtes) +* Requête 2 : `count=1000` et `startIndex=1000` +* Requête 3 : `count=1000` et `startIndex=2000` +* Etc. + +### Authentification + +L'API WFS de Litteralis nécessite des **identifiants** (username / password) à fournir dans la requête HTTP au format `Basic Auth`. + +Ces identifiants doivent être configurés par la collectivité qui nous donne accès à ses données. Ils déterminent les données auxquelles on peut accéder (= celles de la collectivité). + +La MEL a déjà configuré de tels identifiants et nous les a transmis. + +### Couches d'intérêt + +Les données Litteralis contiennent plusieurs "couches" (notion WFS). Toutes ne contiennent pas forcément des données pertinentes pour DiaLog. + +La couche `litteralis:litteralis` doit être intégrée car elle contient les "**emprises**" = assemblages de mesures et de localisations + +**TODO** À explorer : + +* `projet:chantierStandard` +* `projet:chantierExploitant` : "Chantiers pour lesquels vous avez été consultés en tant qu'exploitant du réseau" +* `projet:chantierDeclarant` : "Chantiers que vous avez déclarés" +* `projet:chantierCollectivite` : "Chantiers ayant lieu sur le territoire de votre collectivité" + +## Structure des données + +### Contenu de la réponse de l'API + +La réponse contient une `FeatureCollection` où chaque `Feature` représente une **emprise**. + +Exemple de `Feature` : + +```json +{ + "type": "Feature", + "id": "litteralis.401303", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 3.2192232075, + 50.7031498967 + ], + [ + 3.2192228152, + 50.7030601637 + ], + [ + 3.2190815148, + 50.7030604127 + ], + [ + 3.2190819068, + 50.7031501458 + ], + [ + 3.2192232075, + 50.7031498967 + ] + ] + ] + }, + "geometry_name": "geometry", + "properties": { + "idemprise": 401303, + "idarrete": 850422365, + "shorturl": "https://dl.sogelink.fr/?RTCIgldJ", + "arretesrcid": "2023-0656", + "collectivitelibelle": "VILLE DE WATTRELOS", + "collectiviteid": null, + "collectiviteagencelibelle": "Mairie de Wattrelos", + "collectiviteagenceid": 125777, + "documenttype": "ARRETE TEMPORAIRE", + "arretedebut": "2023-10-31T01:00:00Z", + "arretefin": "2023-12-29T01:00:00Z", + "empriseno": 1, + "emprisetype": "CIRCULATION", + "emprisedebut": "2023-10-31T01:00:00Z", + "emprisefin": "2023-12-29T01:00:00Z", + "mesures": "SOGELINK - Circulation interdite", + "localisations": "À L'INTERSECTION DE LA RUE JEAN JAURÈS, DE LA RUE DE L'ABATTOIR, DE LA RUE DU GÉNÉRAL DE GAULLE ET DE LA RUE JEAN-BAPTISTE LEBAS", + "idagence": 125777, + "fournisseur": "LIPRIME", + "publicationinternet": true, + "emetteurlibelle": "MEL DEA Assainissement", + "emetteurid": null, + "categoriesmodele": "Travaux", + "nommodele": "SOGELINK - AC2 - Arrêté temporaire travaux", + "parametresarrete": "Date de réception de la demande : 31/10/2023 00:00:00 ; Date de début de l'arrêté : 31/10/2023 00:00:00 ; Date de fin de l'arrêté : 29/12/2023 00:00:00 ; Description des travaux : sur réseaux ou ouvrages d'eaux usées / assainissement ; ajout annexe : N ; chargé de MEP de la signalisation : Le demandeur de l'acte", + "parametresemprise": "Dates de l'emprise : Du 31/10/2023 00:00:00 au 29/12/2023 00:00:00", + "parametresmesures": "SOGELINK - Circulation interdite | dérogations : véhicules de l'entreprise exécutant les travaux", + "datecreation": "2023-10-31T10:29:53.053Z", + "datemodification": "2023-10-31T10:29:55.326Z" + }, + "bbox": [ + 3.2190815148, + 50.7030601637, + 3.2192232075, + 50.7031501458 + ] +} +``` + +Les champs pertinents sont les suivants (les descriptions s'inspirent de la documentation Litteralis). Les indications "Check qualité" correspondent à des vérifications de cohérence des données source ; si un data check échoue, cela constituera un cas d'erreur. + +* `geometry` : géométrie GeoJSON de l'emprise telle que dessinée par l'agent dans Litteralis, en coordonnées standard `EPSG:4326`. Empiriquement, peut être de type `Polygon` ou bien `MultiPolygon` (voir "localisations multiples" ci-dessous). +* `idarrete` : identifiant unique de l'arrêté dans Litteralis. En regroupant les features par `idarrete` on obtient la liste des emprises par arrêté. +* `idemprise` : identifiant unique de l'emprise dans Litteralis. **Check qualité** : la réponse doit contenir une seule `Feature` par `idemprise`. +* `shorturl` : lien vers le PDF de l'arrêté papier. +* `arretesrcid` : numéro de l'arrêté, non-unique et propre à l'organisation émettrice. Cela correspond à la notion d'identifiant arrêté (champ `identifier`) dans DiaLog. + * **Check qualité** : le `arretesrcid` des features d'un même arrêté doit être identique. +* `collectiviteagencelibelle` : nom de la collectivité qui a produit l'arrêté + * **Check qualité** : le nom de collectivité des features d'un même arrêté doit être identique. +* `documenttype` : 3 valeurs possibles (définies dans la doc Litteralis) : `ARRETE TEMPORAIRE`, `ARRETE PERMANENT` ou `AUTORISATION VOIRIE` +* `arretedebut` : date de début de l'arrêté dont fait partie l'emprise + * **Check qualité** : format ISO8601 +* `arretefin` : date de fin de l'arrêté dont fait partie l'emprise + * **Check qualité** : format ISO8601, non-vide si et seulement si l'arrêté est temporaire, située strictement plus tard que `arretedebut` +* `emprisetype` : "Le type de l’emprise parmi les valeurs suivantes : `STATIONNEMENT`, `CIRCULATION`, `OCCUPATION`, `DEVIATION`." +* `emprisedebut` et `emprisefin` : dates de début et de fin de l'emprise, non-null uniquement si elles diffèrent de celles de l'arrêté + **Check qualité** : format ISO8601 ; les deux doivent être définies ou bien être `null` (pas de début sans fin ni inversement) ; `emprisefin` située strictement plus tard que `emprisedebut`. +* `mesures` : nom des mesures qui s'appliquent sur cette emprise. Peut contenir plusieurs valeurs séparées par des `;`. +* `localisations` : "Liste des localisations de l'emprise. Peut contenir plusieurs valeurs séparées par des `;`." Exemple : `AVENUE DENIS CORDONNIER (LILLE) - FACE AUX 7 BIS - 90 - 176 ; BOULEVARD DE VERDUN (LILLE) - OPP AU 127;`. +* `categoriesmodele` : "Tel que défini par l’instructeur de l’arrêté dans Littéralis parmi les valeurs suivantes : travaux" (982 sur un échantillon de 1000 emprises), "réglementation permanente" (3 sur 1000), "ODP" (en fait Evenements, 15 sur 1000). Peut correspondre à la `Catégorie` au sens de DiaLog. +* `parametresarrete` : "Liste des informations de l'emprise et leur valeur. L'information est séparée de sa valeur par un `:` et chacun des couples information / valeur est séparé par un `;`." + * **Check qualité** : les emprises d'un même arrêté ont des `parametresarrete` identiques +* `parametresmesures` : "Liste des informations des mesures de circulation / stationnement (pour les `ARRETES`) ou des natures (pour les `AUTORISATION DE VOIRIE`) et leur valeur. Le nom de la mesure (avec un numéro à partir de la deuxième s'il y en a plusieurs) est séparé de l'information par un `|` et l'information est séparée de sa valeur par un `:`. Enfin chacun des couples « mesure | information : valeur » est séparé par un `;`." + +Le champ `parametresemprise` est vide la plupart du temps et contient sinon les dates de l'emprise, redondant avec `emprisedebut` et `emprisefin`. + +#### Analyse du champ `localisations` + +En principe, il y autant d'items dans `localisations` que de polygônes dans `geometry`. + +Par soucis de simplicité et de robustesse du code, on ne cherchera pas vérifier la cohérence de ces deux listes ni à inspecter le contenu de `geometry`. + +On utilisera le type `RawGeoJSON` dont le label prendra la valeur du champ `localisations`, et la géométrie la valeur de `geometry` traitée comme un GeoJSON "opaque". + +#### Analyse du champ `parametresarrete` + +La plupart des informations sont relatives au cycle de vie de l'arrêté ou du chantier et n'ont pas de correspondance dans DiaLog + +Certaines informations peuvent alimenter la `description` de l'arrêté : + +* `Description des travaux` +* `Description de l'évènement` + +#### Analyse des champs `mesures` et `parametresmesures` + +##### Valeurs possibles + +Liste des mesures observées sur un échantillon de 1000 emprises : + +_(Entre parenthèses : le nombre d'occurrences. En gras : les types qui peuvent être intégrés à DiaLog à date)_ + +* ATP - Assainissement (190) +* ATP - Autres réseaux (11) +* ATP - Chauffage urbain (1) +* ATP - Eau potable (136) +* ATP - Electricité (192) +* ATP - Gaz (171) +* ATP - Télécommunications (26) +* ATP - Vidéosurveillance (1) +* ATP - Végétalisation (1) +* ATU - Eau potable (5) +* ATU - Electricité (4) +* ATU - Gaz (25) +* ATU - Télécommunications (3) +* Circulation alternée (5) +* **Circulation interdite** (11) +* Déviation (12) +* Interdiction de dépasser (16) +* Interdiction de stationnement (26) +* **Limitation de vitesse** (22) +* Mesure libre circulation (7) +* Neutralisation de voie (11) +* Rétrécissement de largeur de voie (2) +* SOGELINK - Circulation alternée (10) +* **SOGELINK - Circulation interdite** (26) +* SOGELINK - Circulation à double sens (4) +* SOGELINK - Déviation (8) +* SOGELINK - Interdiction de dépasser (14) +* SOGELINK - Interdiction de stationnement (74) +* SOGELINK - Interruption de circulation travaux (1) +* **SOGELINK - Limitation de vitesse** (15) +* SOGELINK - Mesure libre circulation (5) +* SOGELINK - Mesure libre stationnement (4) +* SOGELINK - Mise en impasse (1) +* SOGELINK - Neutralisation de voie (1) +* SOGELINK - Parcours ou situation de l'événement (1) +* SOGELINK - Rétrécissement temporaire de largeur de voie (45) +* SOGELINK - Sens interdit (ou sens unique) (2) +* SOGELINK - Stationnement CERFA (3) +* SOGELINK - Stationnement hors travaux (7) +* SOGELINK - Stationnement pour travaux (27) +* Stationnement pour travaux (1) +* Voie réservée (1) + +##### Obtention des informations d'une mesure donnée + +On peut récupérer les informations d'une mesure donnée en regroupant les informations de `parametresmesures` par mesure. + +Exemple : dans l'emprise 394604, `mesures` vaut "SOGELINK - Interdiction de stationnement;SOGELINK - Circulation interdite", et `parametresmesures` vaut : + +> SOGELINK - Interdiction de stationnement 2 | jours et horaires : de 08 h 00 à 18 h 00 ; SOGELINK - Interdiction de stationnement 2 | dérogations : véhicules de l'entreprise exécutant les travaux ; SOGELINK - Interdiction de stationnement 2 | caractère aggravant : gênant ; SOGELINK - Interdiction de stationnement 2 | fourrière : Oui ; SOGELINK - Circulation interdite | jours et horaires : de 08 h 00 à 18 h 00 ; SOGELINK - Circulation interdite | dérogations : véhicules de l'entreprise exécutant les travaux + +On voit que la numérotation ne suit pas forcément l'ordre dans la liste : "interdiction de stationnement **2**" mais l'interdiction de stationnement est en premier dans `mesures`. Ce sera à prendre en compte. + +On peut regrouper par nom de mesure et obtenir ainsi les informations par mesure : + +* `Interdiction de stationnement 2` => "jours et horaires : de 08 h 00 à 18 h 00 ; dérogations : véhicules de l'entreprise exécutant les travaux ; caractère aggravant : gênant ; fourrière : Oui" +* `SOGELINK - Circulation interdite` => "jours et horaires : de 08 h 00 à 18 h 00 ; dérogations : véhicules de l'entreprise exécutant les travaux" + +##### Informations notables + +Les informations sur les **véhicules concernés** sont présentes dans `parametresmesures`. Ils semblent séparés par des `,`. Exemples : + +* `SOGELINK - Circulation interdite | véhicules concernés : véhicules de plus de 3.5 tonnes` +* `SOGELINK - Circulation interdite | véhicules concernés : piétons, cycles,poids lourds,véhicules légers` + +De même on a les informations sur les **véhicules exemptés** (séparés par `,`) avec l'information `dérogations` : + +* `SOGELINK - Circulation interdite | dérogations : véhicules relevant de l'organisation de l'événement` +* `SOGELINK - Circulation interdite | dérogations : véhicules de police,véhicules de secours,véhicules de l'entreprise exécutant les travaux` + +Les **périodes** sont précisées dans l'information `jours et horaires`, mais le format n'est pas forcément standardisé : + +* Format quasi-standardisé pour un créneau horaire seul + * `SOGELINK - Circulation interdite | jours et horaires : de 08 h 00 à 18 h 00` + * `jours et horaires : de 10h45 à 11h30` + * `jours et horaires : de 10h45 à 11h30` (H en majuscule) +* Format NON STANDARDISÉ dans le cas où seulement certains jours sont concernés + * `SOGELINK - Circulation interdite | jours et horaires : la journée` (???) + * `SOGELINK - Circulation interdite 3 | jours et horaires : du lundi au vendredi de 7h30 à 17h30` + * `SOGELINK - Circulation interdite | jours et horaires : Samedis, Dimanches et jours fériés ainsi que les jours de semaine de 17h30 à 8h00` + * De nuit + * `jours et horaires : de nuit entre 21H00 et 5H00` + * `jours et horaires : travaux de nuit de 21H00 à 6H00` (pas de `NOM MESURE |`) + +La **vitesse** d'une limitation de vitesse est indiquée dans l'information `limite de vitesse` : + +* `Limitation de vitesse 4 | limite de vitesse : 30 km/h` + +### Correspondance des champs + +Informations générales (`SaveRegulationGeneralInfoCommand`) : + +* `$identifier` => `arretesrcid` +* `$source` => `litteralis` (valeur fixe) +* `$category` => déterminé à partir de `categoriesmodele` +* `$description` => ? +* `$organization` => récupérée à partir d'un UUID donné en paramètre de l'intégration (par exemple l'UUID de l'organisation "Métropole Européenne de Lille (MEL)" préalablement créée dans DiaLog) +* `$startDate` => `arretedebut` +* `$endDate` => `arretefin` + +Mesure (`SaveMeasureCommand`) : une mesure par item dans le champ `mesures` + +* `$type` => déterminé à partir du nom de la mesure `mesures` + * `noEntry` : pour les noms "Circulation interdite" et "SOGELINK - Circulation interdite" + * `speedLimitation` : pour les noms "Limitation de vitesse" et "SOGELINK - Limitation de vitesse" + * Toutes les autres mesures sont ignorées +* `$maxSpeed` => information `limite de vitesse` dans `parametresmesures` +* `$locations` => une liste d'une seule `SaveLocationCommand` qui décrit l'emprise (voir ci-dessous) +* `$periods` => construites à partir des informations `jours et horaires` (voir ci-dessous) +* `$vehicleSet` => construit à partir des informations `véhicules concernés` (champ `restrictedVehicleTypes`) et `dérogations` (champ `exemptedVehicleTypes`) + +Localisation (`SaveLocationCommand`) : + +* Une seule localisation par mesure +* `$roadType` => `rawGeoJSON` (valeur fixe) +* `$rawGeoJSON` => objet `SaveRawGeoJSONCommand` + * `$label` => la valeur du champ `localisations` + * `$geometry` => la valeur du champ `geometry` + +Période (`SavePeriodCommand`) : + +* `$startDate` et `$startTime` => ? +* `$endDate` et `$endTime` => ? +* `$recurrenceType` => ? +* `$dailyRange` => ? +* `$timeSlots` => ? + +### Qualité des données source + +**TODO** prendre plusieurs exemples et vérifier la concordance entre l'arrêté papier et les données fournies par l'API + +## Gestion des erreurs + +TODO + +## Périmètre et volumes d'intégration + +L'intégration Litteralis inspectera les [couches d'intérêt](#couches-dintérêt) identifiées, à savoir `litteralis:litteralis` uniquement. + +Dans chaque couche, elle récupèrera **toutes les emprises** de l'organisation **dont la date de fin (de l'arrêté ou de l'emprise) est dans le futur** (voir [Structure de l'API](#structure-de-lapi) pour l'approche de parcours intégral de l'API). + +Au sein d'une emprise, on intègrera **uniquement les mesures connues de DiaLog**, à savoir Circulation interdite et Limitation de vitesse. + +On intègrera **toutes les localisations** d'une emprise. + +Avant chaque exécution de l'intégration, on supprimera les données (identifiées par `source = 'litteralis'` dans l'organisation cible). + +## Modèle d'exécution + +L'intégration Litteralis sera dans un premier temps exécutée manuellement en exécutant la commande spécifique à la collectivité, tel que décrite ci-dessus. + +On pourra ensuite envisager une automatisation grâce à GitHub Actions, sur le modèle de l'intégration Eudonet Paris. + +## Paramétrage + +L'intégration Litteralis prendra comme paramètres d'entrée : + +* L'identifiant DiaLog de l'organisation cible dans laquelle intégrer les données (par exemple celui de l'organisation "MEL") +* Les identifiants de l'API configurés par la collectivité + +Ces paramètres d'entrée pourront être définies par un jeu variables d'environnement spécifique à chaque collectivité dont on veut intégrer les données Litteralis. + +Par exemple pour la MEL, on pourra créer une commande Symfony qui à exécuter en ligne de commande comme ceci : + +```bash +make console CMD="app:import:litteralis:mel" +``` + +Cette commande spécifique ira lire les variables d'environnement `APP_LITTERALIS_MEL_ORG_ID`, `APP_LITTERALIS_MEL_USERNAME` et `APP_LITTERALIS_MEL_PASSWORD` et les passera à l'intégration Litteralis. + +## Références + +* [Documentation du Flux WFS Litteralis](https://kdrive.infomaniak.com/app/drive/184671/files/53093/preview/pdf/53094) diff --git a/src/Application/Litteralis/Command/ImportLitteralisRegulationCommand.php b/src/Application/Litteralis/Command/ImportLitteralisRegulationCommand.php new file mode 100644 index 000000000..593ce8e55 --- /dev/null +++ b/src/Application/Litteralis/Command/ImportLitteralisRegulationCommand.php @@ -0,0 +1,21 @@ +source = RegulationOrderRecordSourceEnum::LITTERALIS->value; + } +} diff --git a/src/Application/Litteralis/Command/ImportLitteralisRegulationCommandHandler.php b/src/Application/Litteralis/Command/ImportLitteralisRegulationCommandHandler.php new file mode 100644 index 000000000..4c8f523cb --- /dev/null +++ b/src/Application/Litteralis/Command/ImportLitteralisRegulationCommandHandler.php @@ -0,0 +1,33 @@ +commandBus->handle($command->generalInfoCommand); + + $regulationOrder = $regulationOrderRecord->getRegulationOrder(); + + foreach ($command->measureCommands as $measureCommand) { + $measureCommand->regulationOrder = $regulationOrder; + $measure = $this->commandBus->handle($measureCommand); + $regulationOrder->addMeasure($measure); + } + + $this->commandBus->handle(new PublishRegulationCommand($regulationOrderRecord)); + } +} diff --git a/src/Domain/Regulation/Enum/RegulationOrderRecordSourceEnum.php b/src/Domain/Regulation/Enum/RegulationOrderRecordSourceEnum.php index a77faacf0..1ea04d502 100644 --- a/src/Domain/Regulation/Enum/RegulationOrderRecordSourceEnum.php +++ b/src/Domain/Regulation/Enum/RegulationOrderRecordSourceEnum.php @@ -10,4 +10,5 @@ enum RegulationOrderRecordSourceEnum: string case EUDONET_PARIS = 'eudonet_paris'; case BAC_IDF = 'bacidf'; case JOP = 'jop'; + case LITTERALIS = 'litteralis'; } diff --git a/src/Infrastructure/Litteralis/LitteralisExecutor.php b/src/Infrastructure/Litteralis/LitteralisExecutor.php new file mode 100644 index 000000000..d1ee4402a --- /dev/null +++ b/src/Infrastructure/Litteralis/LitteralisExecutor.php @@ -0,0 +1,61 @@ +queryBus->handle(new GetOrganizationByUuidQuery($orgId)); + } catch (OrganizationNotFoundException $exc) { + throw new \RuntimeException(sprintf('Organization not found: %s', $orgId)); + } + + $reporter = $this->reporterFactory->createReporter(); + $reporter->start(); + + $featuresByRegulation = $this->extractor->extractFeaturesByRegulation($reporter); + + foreach ($featuresByRegulation as $regulationFeatures) { + $command = $this->transformer->transform($reporter, $regulationFeatures, $organization); + + if ($command === null) { + // If errors have occurred, they have already been logged to the reporter by the transformer, + // so we should just continue to the next set of features. + continue; + } + + try { + $this->commandBus->handle($command); + } catch (\Exception $exc) { + $reporter->addError($reporter::ERROR_IMPORT_COMMAND_FAILED, [ + 'message' => $exc->getMessage(), + 'violations' => $exc instanceof ValidationFailedException ? iterator_to_array($exc->getViolations()) : null, + 'command' => $command, + ]); + + throw $exc; + } + } + + $reporter->end(); + } +} diff --git a/src/Infrastructure/Litteralis/LitteralisExtractor.php b/src/Infrastructure/Litteralis/LitteralisExtractor.php new file mode 100644 index 000000000..5a602d8f1 --- /dev/null +++ b/src/Infrastructure/Litteralis/LitteralisExtractor.php @@ -0,0 +1,113 @@ +countFeatures($reporter); + $matchingFeatures = $this->countFeatures($reporter, $cqlFilter); + $reporter->onFeatureStats(["Nombre total d'emprises dans Litteralis" => $totalFeatures, "Nombre d'emprises candidates à l'import" => $matchingFeatures, 'Filtre' => $cqlFilter]); + + for ($pageNumber = 1; $pageNumber <= $totalPages; ++$pageNumber) { + $method = 'GET'; + $path = '/maplink/public/wfs'; + $options = [ + 'query' => [ + 'outputFormat' => 'application/json', + 'SERVICE' => 'wfs', + 'VERSION' => '2', + 'REQUEST' => 'GetFeature', + 'TYPENAME' => 'litteralis:litteralis', + 'cql_filter' => $cqlFilter, + 'count' => $numPerPage, + 'startIndex' => $numPerPage * ($pageNumber - 1), + ], + ]; + + $response = $this->makeRequest($reporter, $method, $path, $options); + $content = $response->getcontent(); + $geoJSON = json_decode($content, true); + + if ($totalPages === INF) { + $totalPages = 1; + // TODO activate all + // $totalPages = intdiv($geoJSON['totalFeatures'], $numPerPage) + 1; + } + + foreach ($geoJSON['features'] as $feature) { + $identifier = $feature['properties']['arretesrcid']; + + $geometry = $feature[$feature['properties']['geometryName'] ?? 'geometry']; + + if (!$geometry) { + // Sometimes there is no geometry + $reporter->addWarning($reporter::ERROR_MISSING_GEOMETRY, [ + 'idemprise' => $feature['properties']['idemprise'], + 'arretesrcid' => $identifier, + ]); + continue; + } + + $featuresByRegulation[$identifier][] = $feature; + } + } + + $reporter->onExtract(json_encode($featuresByRegulation, JSON_UNESCAPED_UNICODE & JSON_UNESCAPED_SLASHES)); + + return $featuresByRegulation; + } + + private function makeRequest(LitteralisReporter $reporter, string $method, string $path, array $options): ResponseInterface + { + $reporter->onRequest($method, $path, $options); + $response = $this->litteralisWfsHttpClient->request($method, $path, $options); + $reporter->onResponse($response); + + return $response; + } + + private function countFeatures(LitteralisReporter $reporter, ?string $cqlFilter = null): int + { + $method = 'GET'; + $path = '/maplink/public/wfs'; + $options = [ + 'query' => [ + 'outputFormat' => 'application/json', + 'SERVICE' => 'wfs', + 'VERSION' => '2', + 'REQUEST' => 'GetFeature', + 'TYPENAME' => 'litteralis:litteralis', + 'count' => 1, + 'startIndex' => 0, + ], + ]; + + if ($cqlFilter) { + $options['query']['cql_filter'] = $cqlFilter; + } + + $response = $this->makeRequest($reporter, $method, $path, $options); + $geoJSON = json_decode($response->getContent(), true); + + return $geoJSON['numberMatched']; + } +} diff --git a/src/Infrastructure/Litteralis/LitteralisReporter.php b/src/Infrastructure/Litteralis/LitteralisReporter.php new file mode 100644 index 000000000..145cb962c --- /dev/null +++ b/src/Infrastructure/Litteralis/LitteralisReporter.php @@ -0,0 +1,99 @@ +_hasFatalError = false; + } + + public function start(): void + { + $this->logger->info('started'); + } + + public function end(): void + { + $this->logger->info('done'); + } + + private function setFatalError(): void + { + $this->_hasFatalError = true; + } + + /** + * Return whether a fatal data processing error has occurred, indicating + * that the regulation should not be imported. + */ + public function hasFatalError(): bool + { + return $this->_hasFatalError; + } + + // Hooks + + public function onFeatureStats(array $stats): void + { + $this->logger->info('feature:stats', $stats); + } + + public function onRequest(string $method, string $path, array $options): void + { + $this->logger->debug('request', ['method' => $method, 'path' => $path, 'options' => $options]); + } + + public function onResponse(ResponseInterface $response): void + { + $this->logger->debug('response', ['status' => $response->getStatusCode()]); + } + + public function onExtract(mixed $result): void + { + $this->logger->info('extract:done'); + $this->logger->debug('extract:done:details', ['result' => $result]); + } + + public function addError(string $name, array $context): void + { + $this->logger->error($name, $context); + $this->setFatalError(); + } + + public function addWarning(string $name, array $context): void + { + $this->logger->warning($name, $context); + } + + public function addNotice(string $name, array $context): void + { + $this->logger->debug($name, $context); + } +} diff --git a/src/Infrastructure/Litteralis/LitteralisReporterFactory.php b/src/Infrastructure/Litteralis/LitteralisReporterFactory.php new file mode 100644 index 000000000..62a5ccf92 --- /dev/null +++ b/src/Infrastructure/Litteralis/LitteralisReporterFactory.php @@ -0,0 +1,20 @@ +logger); + } +} diff --git a/src/Infrastructure/Litteralis/LitteralisTransformer.php b/src/Infrastructure/Litteralis/LitteralisTransformer.php new file mode 100644 index 000000000..82474fad8 --- /dev/null +++ b/src/Infrastructure/Litteralis/LitteralisTransformer.php @@ -0,0 +1,442 @@ + MeasureTypeEnum::NO_ENTRY->value, + 'Circulation interdite' => MeasureTypeEnum::NO_ENTRY->value, + 'SOGELINK - Limitation de vitesse' => MeasureTypeEnum::SPEED_LIMITATION->value, + 'Limitation de vitesse' => MeasureTypeEnum::SPEED_LIMITATION->value, + ]; + + public function __construct( + ) { + } + + public function transform( + LitteralisReporter $reporter, + array $regulationFeatures, + Organization $organization, + ): ?ImportLitteralisRegulationCommand { + $properties = $regulationFeatures[0]['properties']; + + $generalInfoCommand = new SaveRegulationGeneralInfoCommand(); + $generalInfoCommand->identifier = $properties['arretesrcid']; + $this->setCategory($generalInfoCommand, $properties, $reporter); + $generalInfoCommand->description = 'TODO'; // TODO + $generalInfoCommand->organization = $organization; + $this->setRegulationDates($generalInfoCommand, $properties, $reporter); + + if ($reporter->hasFatalError()) { + return null; + } + + $measureCommands = []; + + foreach ($regulationFeatures as $feature) { + $locationCommand = $this->parseLocation($feature); + $featureMeasureCommands = $this->parseMeasures($feature['properties'], $reporter); + + foreach ($featureMeasureCommands as $measureCommand) { + $measureCommand->permissions[] = CanUseRawGeoJSON::PERMISSION_NAME; + $measureCommand->addLocation($locationCommand); + $measureCommands[] = $measureCommand; + } + } + + if ($reporter->hasFatalError()) { /* @phpstan-ignore if.alwaysFalse */ + return null; + } + + if (\count($measureCommands) === 0) { + $reporter->addNotice($reporter::NOTICE_NO_MEASURES_FOUND, ['arretesrcid' => $properties['arretesrcid']]); + + return null; + } + + return new ImportLitteralisRegulationCommand($generalInfoCommand, $measureCommands); + } + + private function setCategory(SaveRegulationGeneralInfoCommand $generalInfoCommand, array $properties, LitteralisReporter $reporter): void + { + $documentTypeValue = $properties['documenttype']; + $isPermanentByDocumentType = $documentTypeValue === 'ARRETE PERMANENT'; + + $categoriesModeleValue = $properties['categoriesmodele']; + $generalInfoCommand->category = match ($categoriesModeleValue) { + 'Travaux' => RegulationOrderCategoryEnum::ROAD_MAINTENANCE->value, + 'Réglementation permanente' => RegulationOrderCategoryEnum::PERMANENT_REGULATION->value, + 'Evenements' => RegulationOrderCategoryEnum::EVENT->value, + default => RegulationOrderCategoryEnum::OTHER->value, + }; + + $isPermanentByCategory = $generalInfoCommand->category === RegulationOrderCategoryEnum::PERMANENT_REGULATION->value; + + // Check that the "permanent" status is consistent between 'documenttype' and 'categoriesmodele' + if ($isPermanentByCategory !== $isPermanentByDocumentType) { + $reporter->addWarning($reporter::WARNING_PERMANENT_CATEGORY_MISMATCH, [ + 'arretesrcid' => $properties['arretesrcid'], + 'documenttype' => $documentTypeValue, + 'categoriesmodele' => $categoriesModeleValue, + ]); + } + + if ($generalInfoCommand->category === RegulationOrderCategoryEnum::OTHER->value) { + $generalInfoCommand->otherCategoryText = $categoriesModeleValue; + } + } + + private function setRegulationDates(SaveRegulationGeneralInfoCommand $command, array $properties, LitteralisReporter $reporter): void + { + $startDate = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ISO8601, $properties['arretedebut']); + + if ($startDate === false) { + $reporter->addError($reporter::ERROR_REGULATION_START_DATE_PARSING_FAILED, [ + 'arretesrcid' => $properties['arretesrcid'], + 'arretedebut' => $properties['arretedebut'], + ]); + } + + $command->startDate = $startDate; + + if ($properties['arretefin'] === null) { + // It's a temporary regulation + return; + } + + $endDate = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ISO8601, $properties['arretefin']); + + if ($endDate === false) { + $reporter->addError($reporter::ERROR_REGULATION_END_DATE_PARSING_FAILED, [ + 'arretesrcid' => $properties['arretesrcid'], + 'arretefin' => $properties['arretefin'], + ]); + } + + $command->endDate = $endDate; + } + + private function parseLocation(array $feature): SaveLocationCommand + { + $properties = $feature['properties']; + $label = $properties['localisations']; + + // Adhere to DB column length + if (\strlen($label) > 255) { + $suffix = ' [...]'; + $label = substr($label, 0, 255 - \strlen($suffix)) . $suffix; + } + + $locationCommand = new SaveLocationCommand(); + $locationCommand->roadType = RoadTypeEnum::RAW_GEOJSON->value; + $locationCommand->rawGeoJSON = new SaveRawGeoJSONCommand(); + $locationCommand->rawGeoJSON->geometry = json_encode($feature[$properties['geometryName'] ?? 'geometry']); + $locationCommand->rawGeoJSON->label = $label; + + return $locationCommand; + } + + /** + * @return SaveMeasureCommand[] + */ + private function parseMeasures(array $properties, LitteralisReporter $reporter): array + { + // D'abord on rassemble les "paramètres" de chaque mesure. + + $parametersByMeasureName = $this->gatherMeasureParameters($properties, $reporter); + + // Ensuite, on traite chaque mesure en interprétant ses paramètres. + + $measureCommands = []; + + foreach ($parametersByMeasureName as $name => $parameters) { + $measureCommand = new SaveMeasureCommand(); + $measureCommand->type = self::MEASURE_MAP[$name]; + + if ($measureCommand->type === MeasureTypeEnum::SPEED_LIMITATION->value) { + $measureCommand->maxSpeed = $this->parseMaxSpeed($properties, $parameters, $reporter); + } + + $measureCommand->vehicleSet = $this->parseVehicleSet($parameters, $reporter); + $measureCommand->periods = $this->parsePeriods($properties, $reporter); + + $measureCommands[] = $measureCommand; + } + + return $measureCommands; + } + + private function gatherMeasureParameters(array $properties, LitteralisReporter $reporter): array + { + // NOTE: Ces commentaires sont en français pour faciliter la compréhension. + + // Le champ 'mesures' d'une emprise contient une liste de noms de mesures, séparés par des ';' + // Par exemple: 'Circulation alternée;Interdiction de dépasser;Interdiction de stationnement;Limitation de vitesse' + $allMeasureNames = explode(';', $properties['mesures']); + + // On ne garde que les mesures que l'on peut intégrer + $measureNames = []; + + foreach ($allMeasureNames as $name) { + if (!\array_key_exists($name, self::MEASURE_MAP)) { + $reporter->addNotice($reporter::NOTICE_UNSUPPORTED_MEASURE, ['name' => $name]); + continue; + } + + $measureNames[] = $name; + } + + // Pour chaque nom de mesure, il y a un ensemble correspondant de "paramètres" dans le champ 'parametresmesures' qui précisent la mesure. + // Par exemple: 'Circulation alternée | type d'alternat : feux et K10 ; Circulation alternée | jours et horaires : de 20H00 à 6H00 ; Limitation de vitesse 4 | limite de vitesse : 30 km/h' + + // Le format est standardisé mais un peu complexe. + // C'est une liste d'éléments au format 'NAME | KEY : VALUE', séparés par des point-virgules. + // Le NAME correspond au nom de la mesure tel qu'il est présent dans le champ 'mesures'. + $measureParameters = $this->parseSeparatedString($properties['parametresmesures'], ';'); + + // D'après la documentation Litteralis, un numéro PEUT être ajouté au NAME. + // Par exemple : "Interdiction de stationnement 3", ou encore "Limitation de vitesse 4". + // En général il correspond à l'index démarrant à 1 de la mesure dans le champ 'mesures'. + // Mais parfois ce n'est pas le cas... Par exemple : + // * 'mesures' = 'SOGELINK - Interdiction de stationnement;SOGELINK - Circulation interdite' + // * 'parametresmesures' = 'Interdiction de stationnement 2 | jours et horaires : de 08 h 00 à 18 h 00 ; SOGELINK - Circulation interdite | jours et horaires : de 08 h 00 à 18 h 00' + // Ici 'Interdiction de stationnement' est en position 1 dans 'mesures', mais a le numéro 2 dans 'parametresmesures', + // tandis que 'SOGELINK - Circulation interdite' qui est en position 2 n'a pas de numéro. + // Comme ce cas est imprévisible, nous le traiterons comme une erreur. + + // On rassemble les paramètres par nom de mesure pour obtenir un array de ce type : + // [ + // 'Circulation alternée' => ["type d'alternat : feux et K10", "jours et horaires : de 20H00 à 6H00"], + // 'Limitation de vitesse' => ['limite de vitesse : 30 km/h'], + // ] + + $parametersByMeasureName = []; + + foreach ($measureParameters as $params) { + // Exemple : "Circulation interdite 2 | dérogation : urgences" -> ['Circulation interdite 2', 'dérogation : urgences'] + [$name, $item] = $this->parseSeparatedString($params, '|'); + + if (preg_match('/^(?P\w+) (?P\d+)$/i', $name, $matches)) { + // Si un numéro est indiqué, il doit correspondre au index commençant à 1 de la mesure dans le champ 'mesures'. + $cleanedName = $matches['name']; + $number = $matches['number']; + $index = array_search($cleanedName, $measureNames); + + if ($index === false) { + // Ce paramètre ne correspond à aucune mesure. + // C'est probablement un type de mesure qu'on n'importe pas. + // Pas d'erreur à signaler. + continue; + } + + if ($number !== $index + 1) { + // Le numéro indiqué ne correspond pas au 1-index de la mesure dans 'mesures'. + // On traite ce cas comme une erreur car on ne peut pas savoir à quelle mesure ces paramètres se rattachent. + $reporter->addError($reporter::ERROR_MEASURE_PARAMETER_INCONSISTENT_NUMBER, [ + 'measureName' => $name, + 'expected' => $index + 1, + 'actual' => $number, + ]); + } + + $name = $cleanedName; + } else { + $index = array_search($name, $measureNames); + + if ($index === false) { + // Ce paramètre ne correspond à aucune mesure. + // C'est probablement un type de mesure qu'on n'importe pas. + // Pas d'erreur à signaler. + continue; + } + } + + // Exemple : "dérogation : urgences" -> ['dérogation', 'urgences'] + [$key, $value] = explode(' : ', $item, 2); + + $parametersByMeasureName[$name][] = [$key, $value]; + } + + return $parametersByMeasureName; + } + + private function parseVehicleSet(array $parameters, LitteralisReporter $reporter): SaveVehicleSetCommand + { + $vehicleSetCommand = new SaveVehicleSetCommand(); + + // Traitement des véhicules concernés + + // Exemple de valeur : "piétons, cycles,poids lourds,véhicules légers" + $vehiculesConcernes = $this->parseSeparatedString($this->findParameterValue($parameters, 'véhicules concernés') ?? '', ','); + + $restrictedTypes = []; + $otherRestrictedTypes = []; + $otherRestrictedTypeText = null; + + foreach ($vehiculesConcernes as $value) { + $vehicleType = match ($value) { + 'piétons' => VehicleTypeEnum::PEDESTRIANS->value, + 'cycles' => VehicleTypeEnum::BICYCLE->value, + 'poids lourds' => VehicleTypeEnum::HEAVY_GOODS_VEHICLE->value, + 'véhicules de plus de 3.5 tonnes' => VehicleTypeEnum::HEAVY_GOODS_VEHICLE->value, + default => null, + }; + + if ($vehicleType === null) { + $otherRestrictedTypes[] = $value; + continue; + } + + $restrictedTypes[] = $vehicleType; + + if ($vehicleType === VehicleTypeEnum::HEAVY_GOODS_VEHICLE->value) { + $vehicleSetCommand->heavyweightMaxWeight = 3.5; + } + } + + if (\count($otherRestrictedTypes) > 0) { + $restrictedTypes[] = VehicleTypeEnum::OTHER->value; + $otherRestrictedTypeText = implode(', ', $otherRestrictedTypes); + } + + $vehicleSetCommand->restrictedTypes = $restrictedTypes; + $vehicleSetCommand->otherRestrictedTypeText = $otherRestrictedTypeText; + + $vehicleSetCommand->allVehicles = empty($vehicleSetCommand->restrictedTypes); + + // Traitement des véhicules exemptés ("Sauf...") + // Exemple de valeur : "véhicules de l'entreprise effectuant les travaux,véhicules de déménagement" + // Le champ "dérogations" ne contient que des types inconnus de DiaLog, donc on met tout dans "Autre" + $derogations = $this->findParameterValue($parameters, 'dérogations'); + $vehicleSetCommand->exemptedTypes = $derogations ? [VehicleTypeEnum::OTHER->value] : []; + $vehicleSetCommand->otherExemptedTypeText = $derogations; + + return $vehicleSetCommand; + } + + private function parsePeriods(array $properties, LitteralisReporter $reporter): array + { + $periodCommand = new SavePeriodCommand(); + + $dateFormat = \DateTimeInterface::ISO8601; + + $startDateProperty = $properties['emprisedebut'] ? 'emprisedebut' : 'arretedebut'; + $startDate = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ISO8601, $properties[$startDateProperty]); + + if (!$startDate) { + $reporter->addError( + $reporter::ERROR_DATE_PARSING_FAILED, + [ + 'idemprise' => $properties['idemprise'], + $startDateProperty => $properties[$startDateProperty], + 'format' => $dateFormat, + ], + ); + } + + $periodCommand->startDate = $startDate; + $periodCommand->startTime = $startDate; + + $endDateProperty = $properties['emprisefin'] ? 'emprisefin' : 'arretefin'; + $periodCommand->isPermanent = true; + + if ($properties[$endDateProperty]) { + $periodCommand->isPermanent = false; + + $endDate = \DateTimeImmutable::createFromFormat(\DateTimeInterface::ISO8601, $properties[$endDateProperty]); + + if (!$endDate) { + $reporter->addError( + $reporter::ERROR_DATE_PARSING_FAILED, + [ + 'idemprise' => $properties['idemprise'], + $endDateProperty => $properties[$endDateProperty], + 'format' => $dateFormat, + ], + ); + } + + $periodCommand->endDate = $endDate; + $periodCommand->endTime = $endDate; + } + + // TODO: parse 'jours et horaires' parameter + $periodCommand->recurrenceType = PeriodRecurrenceTypeEnum::EVERY_DAY->value; + $periodCommand->dailyRange = null; + $periodCommand->timeSlots = []; + + return [$periodCommand]; + } + + private function parseMaxSpeed(array $properties, array $parameters, LitteralisReporter $reporter): ?int + { + $value = $this->findParameterValue($parameters, 'limite de vitesse'); + + if (!$value) { + $reporter->addError($reporter::ERROR_MAX_SPEED_VALUE_MISSING, ['idemprise' => $properties['idemprise']]); + + return null; + } + + if (!preg_match('/^(?P\d+)/i', $value, $matches)) { + $reporter->addError($reporter::ERROR_MAX_SPEED_PARSING_FAILED, ['idemprise' => $properties['idemprise'], 'limite de vitesse' => $value]); + + return null; + } + + return (int) $matches['speed']; + } + + // Utilities + + private function findParameterValue(array $parameters, string $theKey): ?string + { + foreach ($parameters as [$key, $value]) { + if ($key === $theKey) { + return $value; + } + } + + return null; + } + + private function parseSeparatedString(string $string, string $sep): array + { + if (!$string) { + return []; + } + + $rawValues = explode($sep, $string); + + $values = []; + + foreach ($rawValues as $v) { + $cleanedValue = trim($v); + + if ($cleanedValue) { + $values[] = $cleanedValue; + } + } + + return $values; + } +} diff --git a/src/Infrastructure/Persistence/Doctrine/Repository/Regulation/LocationRepository.php b/src/Infrastructure/Persistence/Doctrine/Repository/Regulation/LocationRepository.php index cee6648f6..14a34ea04 100644 --- a/src/Infrastructure/Persistence/Doctrine/Repository/Regulation/LocationRepository.php +++ b/src/Infrastructure/Persistence/Doctrine/Repository/Regulation/LocationRepository.php @@ -96,6 +96,7 @@ public function findAllForMapAsGeoJSON( INNER JOIN regulation_order_record AS roc ON ro.uuid = roc.regulation_order_uuid WHERE roc.status = :status AND l.geometry IS NOT NULL + AND roc.source = :source %s %s ', @@ -105,6 +106,7 @@ public function findAllForMapAsGeoJSON( [ 'status' => RegulationOrderRecordStatusEnum::PUBLISHED, 'now' => $this->dateUtils->getNow()->format('Y-m-d'), + 'source' => 'litteralis', ], ); diff --git a/src/Infrastructure/Symfony/Command/LitteralisImportCommand.php b/src/Infrastructure/Symfony/Command/LitteralisImportCommand.php new file mode 100644 index 000000000..b3d978b23 --- /dev/null +++ b/src/Infrastructure/Symfony/Command/LitteralisImportCommand.php @@ -0,0 +1,40 @@ +executor->execute($orgId); + } catch (\RuntimeException $exc) { + $output->writeln($exc->getMessage()); + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +}