From 71406705eb8bd45953c4a53d17e834a9e3c0be29 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?BITARD=20Micha=C3=ABl?= <michael.bitard@beta.gouv.fr>
Date: Mon, 31 Mar 2025 15:35:02 +0000
Subject: [PATCH] =?UTF-8?q?refactor(api):=20g=C3=A8re=20correctement=20la?=
 =?UTF-8?q?=20modification=20d'une=20=C3=A9tape=20dont=20la=20date=20est?=
 =?UTF-8?q?=20la=20m=C3=AAme=20que=20d'autres=20=C3=A9tapes=20(pub/pnm-pub?=
 =?UTF-8?q?lic/camino!1689)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../api/src/api/rest/perimetre.queries.ts     |  28 +-
 packages/api/src/api/rest/perimetre.ts        |   1 +
 packages/api/src/api/rest/titres.ts           |  11 +-
 .../rules-demarches/axm/oct.machine.test.ts   |  29 +-
 .../rules-demarches/machine-helper.test.ts    | 419 +++++++++---------
 .../rules-demarches/machine-helper.ts         |  53 ++-
 .../procedure-specifique.machine.test.ts      |   4 +-
 .../titre-demarche-etat-validate.test.ts      | 224 ++++++++++
 .../titre-demarche-etat-validate.ts           |  37 +-
 packages/api/src/tools/fp-tools.ts            |   5 +-
 10 files changed, 539 insertions(+), 272 deletions(-)

diff --git a/packages/api/src/api/rest/perimetre.queries.ts b/packages/api/src/api/rest/perimetre.queries.ts
index a76f15078..5e0b3ebeb 100644
--- a/packages/api/src/api/rest/perimetre.queries.ts
+++ b/packages/api/src/api/rest/perimetre.queries.ts
@@ -14,7 +14,7 @@ import { foretIdValidator } from 'camino-common/src/static/forets'
 import { sdomZoneIdValidator } from 'camino-common/src/static/sdom'
 import { KM2, M2, km2Validator, m2Validator } from 'camino-common/src/number'
 import { isNullOrUndefined, onlyUnique } from 'camino-common/src/typescript-tools'
-import { ZodUnparseable, zodParseEffect, zodParseEffectCallback } from '../../tools/fp-tools'
+import { zodParseEffect, zodParseEffectTyped, ZodUnparseable } from '../../tools/fp-tools'
 import { CaminoError } from 'camino-common/src/zod-tools'
 import { Effect, pipe } from 'effect'
 import { departementIdValidator, toDepartementId } from 'camino-common/src/static/departement'
@@ -58,14 +58,7 @@ export const convertPoints = <T extends z.ZodTypeAny>(
       coordinates => coordinates.length === geojsonPoints.features.length,
       () => ({ message: convertPointsInvalidNumberOfFeaturesError })
     ),
-    Effect.flatMap((result: [number, number][]) => {
-      const check = zodParseEffect(arrayTuple4326CoordinateValidator, result)
-
-      return Effect.matchEffect(check, {
-        onSuccess: () => Effect.succeed(result),
-        onFailure: error => Effect.fail({ ...error, message: invalidSridError, detail: 'Vérifiez que le géosystème correspond bien à celui du fichier' }),
-      })
-    }),
+    Effect.tap((result: [number, number][]) => zodParseEffectTyped(arrayTuple4326CoordinateValidator, result, invalidSridError, 'Vérifiez que le géosystème correspond bien à celui du fichier')),
     Effect.map(coordinates => {
       return {
         type: 'FeatureCollection',
@@ -87,14 +80,14 @@ const perimetreInvalideError = "Le périmètre n'est pas valide dans le référe
 const conversionGeometrieError = 'Impossible de convertir la géométrie en JSON' as const
 const getGeojsonByGeoSystemeIdValidator = z.object({ geojson: multiPolygonValidator })
 const polygon4326CoordinatesValidator = z.array(z.array(arrayTuple4326CoordinateValidator.min(3)).min(1)).min(1)
-
+const transformationImpossible = 'Impossible de transformer le geojson dans le référentiel donné' as const
 export type GetGeojsonByGeoSystemeIdErrorMessages =
   | EffectDbQueryAndValidateErrors
-  | ZodUnparseable
   | typeof conversionSystemeError
   | typeof perimetreInvalideError
   | typeof conversionGeometrieError
   | typeof invalidSridError
+  | typeof transformationImpossible
 export const getGeojsonByGeoSystemeId = (
   pool: Pool,
   fromGeoSystemeId: GeoSystemeId,
@@ -117,16 +110,7 @@ export const getGeojsonByGeoSystemeId = (
       result => result.length === 1,
       () => ({ message: conversionSystemeError, extra: to4326GeoSystemeId })
     ),
-    Effect.flatMap(result => {
-      const coordinates: [number, number][][][] = result[0].geojson.coordinates
-
-      const check = zodParseEffect(polygon4326CoordinatesValidator, coordinates)
-
-      return Effect.matchEffect(check, {
-        onSuccess: () => Effect.succeed(result),
-        onFailure: error => Effect.fail({ ...error, message: invalidSridError, detail: 'Vérifiez que le géosystème correspond bien à celui du fichier' }),
-      })
-    }),
+    Effect.tap(result => zodParseEffectTyped(polygon4326CoordinatesValidator, result[0].geojson.coordinates, invalidSridError, 'Vérifiez que le géosystème correspond bien à celui du fichier')),
     Effect.map(result => {
       if (fromGeoSystemeId === to4326GeoSystemeId) {
         return geojson
@@ -139,7 +123,7 @@ export const getGeojsonByGeoSystemeId = (
 
       return feature
     }),
-    Effect.flatMap(zodParseEffectCallback(featureMultiPolygonValidator))
+    Effect.flatMap(result => zodParseEffectTyped(featureMultiPolygonValidator, result, transformationImpossible))
   )
 }
 
diff --git a/packages/api/src/api/rest/perimetre.ts b/packages/api/src/api/rest/perimetre.ts
index 8015b9c44..f51a56eda 100644
--- a/packages/api/src/api/rest/perimetre.ts
+++ b/packages/api/src/api/rest/perimetre.ts
@@ -415,6 +415,7 @@ export const geojsonImport: RestNewPostCall<'/rest/geojson/import/:geoSystemeId'
           'Une erreur inattendue est survenue lors de la récupération des informations geojson en base',
           "Impossible d'exécuter la requête dans la base de données",
           'Les données en base ne correspondent pas à ce qui est attendu',
+          'Impossible de transformer le geojson dans le référentiel donné',
           () => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })
         ),
         Match.whenOr(
diff --git a/packages/api/src/api/rest/titres.ts b/packages/api/src/api/rest/titres.ts
index 709ef81db..3f31cf708 100644
--- a/packages/api/src/api/rest/titres.ts
+++ b/packages/api/src/api/rest/titres.ts
@@ -155,10 +155,17 @@ export const titresAdministrations =
                 derniereEtape = etapesDerniereDemarche[etapesDerniereDemarche.length - 1]
                 if (isNotNullNorUndefined(machine)) {
                   try {
-                    enAttenteDeAdministration = machine.whoIsBlocking(etapesDerniereDemarche).includes(user.administrationId)
+                    const whoIsBlocking = machine.whoIsBlocking(etapesDerniereDemarche)
+                    if (!whoIsBlocking.valid) {
+                      throw new Error(whoIsBlocking.error)
+                    }
+                    enAttenteDeAdministration = whoIsBlocking.value.includes(user.administrationId)
                     const nextEtapes = machine.possibleNextEtapes(etapesDerniereDemarche, getCurrent())
+                    if (!nextEtapes.valid) {
+                      throw new Error(nextEtapes.error)
+                    }
                     prochainesEtapes.push(
-                      ...nextEtapes
+                      ...nextEtapes.value
                         .map(etape => etape.etapeTypeId)
                         .filter(onlyUnique)
                         .filter(etape => !etapesAMasquer.includes(etape))
diff --git a/packages/api/src/business/rules-demarches/axm/oct.machine.test.ts b/packages/api/src/business/rules-demarches/axm/oct.machine.test.ts
index 616a7a027..03fe94628 100644
--- a/packages/api/src/business/rules-demarches/axm/oct.machine.test.ts
+++ b/packages/api/src/business/rules-demarches/axm/oct.machine.test.ts
@@ -19,7 +19,7 @@ describe('vérifie l’arbre d’octroi d’AXM', () => {
         "ENREGISTRER_DEMANDE (confidentielle, déposé                 ) -> [DEMANDER_COMPLEMENTS_POUR_RECEVABILITE,FAIRE_CLASSEMENT_SANS_SUITE,FAIRE_DESISTEMENT_DEMANDEUR,FAIRE_RECEVABILITE_DEMANDE_DEFAVORABLE,FAIRE_RECEVABILITE_DEMANDE_FAVORABLE,RENDRE_DECISION_IMPLICITE_REJET]",
       ]
     `)
-    expect(axmOctMachine.whoIsBlocking(etapes)).toStrictEqual([ADMINISTRATION_IDS['DGTM - GUYANE']])
+    expect(axmOctMachine.whoIsBlocking(etapes)).toStrictEqual({ valid: true, value: [ADMINISTRATION_IDS['DGTM - GUYANE']] })
   })
 
   test('peut faire l’avis du DREAL sans aucun autre avis 30 jours après la saisine des services', () => {
@@ -42,9 +42,13 @@ describe('vérifie l’arbre d’octroi d’AXM', () => {
         "RENDRE_AVIS_DREAL                                     (publique      , en instruction         ) -> [FAIRE_CLASSEMENT_SANS_SUITE,FAIRE_DESISTEMENT_DEMANDEUR,FAIRE_SAISINE_COMMISSION_DEPARTEMENTALE_DES_MINES,RENDRE_AVIS_COMMISSION_DEPARTEMENTALE_DES_MINES,RENDRE_AVIS_COMMISSION_DEPARTEMENTALE_DES_MINES_AJOURNE]",
       ]
     `)
+    const possibleNextEtapes = machine.possibleNextEtapes(etapes, toCaminoDate('2022-06-15'))
+    expect(possibleNextEtapes.valid).toBe(true)
+    if (!possibleNextEtapes.valid) {
+      throw new Error(possibleNextEtapes.error)
+    }
     expect(
-      machine
-        .possibleNextEtapes(etapes, toCaminoDate('2022-06-15'))
+      possibleNextEtapes.value
         .map(({ type }) => type)
         .filter(onlyUnique)
         .toSorted()
@@ -134,7 +138,7 @@ describe('vérifie l’arbre d’octroi d’AXM', () => {
         "FAIRE_CLASSEMENT_SANS_SUITE (confidentielle, classé sans suite      ) -> []",
       ]
     `)
-    expect(machine.whoIsBlocking(etapes)).toStrictEqual([])
+    expect(machine.whoIsBlocking(etapes)).toStrictEqual({ valid: true, value: [] })
   })
 
   test('ne peut pas faire deux fois la même étape à la même date', () => {
@@ -204,9 +208,13 @@ describe('vérifie l’arbre d’octroi d’AXM', () => {
       ETES.avisDesCollectivites.FAIT,
       ETES.avisDesServicesEtCommissionsConsultatives.FAIT,
     ])
+    let possibleNextEtapes = machine.possibleNextEtapes(etapes, toCaminoDate('2022-05-06'))
+    expect(possibleNextEtapes.valid).toBe(true)
+    if (!possibleNextEtapes.valid) {
+      throw new Error(possibleNextEtapes.error)
+    }
     expect(
-      machine
-        .possibleNextEtapes(etapes, toCaminoDate('2022-05-06'))
+      possibleNextEtapes.value
         .map(({ type }) => type)
         .filter(onlyUnique)
         .toSorted()
@@ -218,9 +226,14 @@ describe('vérifie l’arbre d’octroi d’AXM', () => {
         "RENDRE_AVIS_DREAL",
       ]
     `)
+
+    possibleNextEtapes = machine.possibleNextEtapes(etapes, toCaminoDate('2022-05-05'))
+    expect(possibleNextEtapes.valid).toBe(true)
+    if (!possibleNextEtapes.valid) {
+      throw new Error(possibleNextEtapes.error)
+    }
     expect(
-      machine
-        .possibleNextEtapes(etapes, toCaminoDate('2022-05-05'))
+      possibleNextEtapes.value
         .map(({ type }) => type)
         .filter(onlyUnique)
         .toSorted()
diff --git a/packages/api/src/business/rules-demarches/machine-helper.test.ts b/packages/api/src/business/rules-demarches/machine-helper.test.ts
index 671000eda..da8b1a570 100644
--- a/packages/api/src/business/rules-demarches/machine-helper.test.ts
+++ b/packages/api/src/business/rules-demarches/machine-helper.test.ts
@@ -364,7 +364,7 @@ describe('whoIsBlocking', () => {
           date: toCaminoDate('2021-02-03'),
         },
       ])
-    ).toStrictEqual([ADMINISTRATION_IDS['DGTM - GUYANE']])
+    ).toStrictEqual({ valid: true, value: [ADMINISTRATION_IDS['DGTM - GUYANE']] })
   })
 
   test('on attend la DGTM pour la validation du paiement des frais de dossier', () => {
@@ -391,7 +391,7 @@ describe('whoIsBlocking', () => {
           date: toCaminoDate('2021-02-04'),
         },
       ])
-    ).toStrictEqual([ADMINISTRATION_IDS['DGTM - GUYANE']])
+    ).toStrictEqual({ valid: true, value: [ADMINISTRATION_IDS['DGTM - GUYANE']] })
   })
 
   test('on attend personne', () => {
@@ -428,7 +428,7 @@ describe('whoIsBlocking', () => {
           date: toCaminoDate('2021-02-06'),
         },
       ])
-    ).toStrictEqual([])
+    ).toStrictEqual({ valid: true, value: [] })
   })
 })
 
@@ -451,36 +451,39 @@ describe('mainStep', () => {
         toCaminoDate('2021-02-03')
       )
     ).toMatchInlineSnapshot(`
-      [
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "css",
-          "mainStep": false,
-          "type": "CLASSER_SANS_SUITE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "des",
-          "mainStep": false,
-          "type": "DESISTER_PAR_LE_DEMANDEUR",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "mod",
-          "mainStep": false,
-          "type": "MODIFIER_DEMANDE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "pfd",
-          "mainStep": true,
-          "type": "PAYER_FRAIS_DE_DOSSIER",
-        },
-      ]
+      {
+        "valid": true,
+        "value": [
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "css",
+            "mainStep": false,
+            "type": "CLASSER_SANS_SUITE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "des",
+            "mainStep": false,
+            "type": "DESISTER_PAR_LE_DEMANDEUR",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "mod",
+            "mainStep": false,
+            "type": "MODIFIER_DEMANDE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "pfd",
+            "mainStep": true,
+            "type": "PAYER_FRAIS_DE_DOSSIER",
+          },
+        ],
+      }
     `)
   })
   test('possibleNextEtapes après une recevabilité favorable on rendre un avis des services et commissions consultatives', () => {
@@ -497,50 +500,53 @@ describe('mainStep', () => {
         toCaminoDate('2021-02-03')
       )
     ).toMatchInlineSnapshot(`
-      [
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "asc",
-          "mainStep": true,
-          "type": "RENDRE_AVIS_DES_SERVICES_ET_COMMISSIONS_CONSULTATIVES",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "css",
-          "mainStep": false,
-          "type": "CLASSER_SANS_SUITE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "des",
-          "mainStep": false,
-          "type": "DESISTER_PAR_LE_DEMANDEUR",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fav",
-          "etapeTypeId": "exp",
-          "mainStep": false,
-          "type": "RECEVOIR_EXPERTISE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "def",
-          "etapeTypeId": "exp",
-          "mainStep": false,
-          "type": "RECEVOIR_EXPERTISE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "mod",
-          "mainStep": false,
-          "type": "MODIFIER_DEMANDE",
-        },
-      ]
+      {
+        "valid": true,
+        "value": [
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "asc",
+            "mainStep": true,
+            "type": "RENDRE_AVIS_DES_SERVICES_ET_COMMISSIONS_CONSULTATIVES",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "css",
+            "mainStep": false,
+            "type": "CLASSER_SANS_SUITE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "des",
+            "mainStep": false,
+            "type": "DESISTER_PAR_LE_DEMANDEUR",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fav",
+            "etapeTypeId": "exp",
+            "mainStep": false,
+            "type": "RECEVOIR_EXPERTISE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "def",
+            "etapeTypeId": "exp",
+            "mainStep": false,
+            "type": "RECEVOIR_EXPERTISE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "mod",
+            "mainStep": false,
+            "type": "MODIFIER_DEMANDE",
+          },
+        ],
+      }
     `)
   })
   test('après un avis des services et commissions consultatives on doit avoir la saisine de la commission des autorisations de recherches minières', () => {
@@ -558,50 +564,53 @@ describe('mainStep', () => {
         toCaminoDate('2021-02-03')
       )
     ).toMatchInlineSnapshot(`
-      [
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "css",
-          "mainStep": false,
-          "type": "CLASSER_SANS_SUITE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "des",
-          "mainStep": false,
-          "type": "DESISTER_PAR_LE_DEMANDEUR",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fav",
-          "etapeTypeId": "exp",
-          "mainStep": false,
-          "type": "RECEVOIR_EXPERTISE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "def",
-          "etapeTypeId": "exp",
-          "mainStep": false,
-          "type": "RECEVOIR_EXPERTISE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "mod",
-          "mainStep": false,
-          "type": "MODIFIER_DEMANDE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "sca",
-          "mainStep": true,
-          "type": "FAIRE_SAISINE_CARM",
-        },
-      ]
+      {
+        "valid": true,
+        "value": [
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "css",
+            "mainStep": false,
+            "type": "CLASSER_SANS_SUITE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "des",
+            "mainStep": false,
+            "type": "DESISTER_PAR_LE_DEMANDEUR",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fav",
+            "etapeTypeId": "exp",
+            "mainStep": false,
+            "type": "RECEVOIR_EXPERTISE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "def",
+            "etapeTypeId": "exp",
+            "mainStep": false,
+            "type": "RECEVOIR_EXPERTISE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "mod",
+            "mainStep": false,
+            "type": "MODIFIER_DEMANDE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "sca",
+            "mainStep": true,
+            "type": "FAIRE_SAISINE_CARM",
+          },
+        ],
+      }
     `)
   })
   test('après la validation de frais de paiement on doit faire une recevabilité', () => {
@@ -617,57 +626,60 @@ describe('mainStep', () => {
         toCaminoDate('2021-02-03')
       )
     ).toMatchInlineSnapshot(`
-      [
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "css",
-          "mainStep": false,
-          "type": "CLASSER_SANS_SUITE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "des",
-          "mainStep": false,
-          "type": "DESISTER_PAR_LE_DEMANDEUR",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "mca",
-          "mainStep": false,
-          "type": "DEMANDER_COMPLEMENTS_MCR",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "def",
-          "etapeTypeId": "mcr",
-          "mainStep": false,
-          "type": "DECLARER_DEMANDE_DEFAVORABLE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fav",
-          "etapeTypeId": "mcr",
-          "mainStep": true,
-          "type": "DECLARER_DEMANDE_FAVORABLE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "mim",
-          "mainStep": false,
-          "type": "DEMANDER_INFORMATION_MCR",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "mod",
-          "mainStep": false,
-          "type": "MODIFIER_DEMANDE",
-        },
-      ]
+      {
+        "valid": true,
+        "value": [
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "css",
+            "mainStep": false,
+            "type": "CLASSER_SANS_SUITE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "des",
+            "mainStep": false,
+            "type": "DESISTER_PAR_LE_DEMANDEUR",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "mca",
+            "mainStep": false,
+            "type": "DEMANDER_COMPLEMENTS_MCR",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "def",
+            "etapeTypeId": "mcr",
+            "mainStep": false,
+            "type": "DECLARER_DEMANDE_DEFAVORABLE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fav",
+            "etapeTypeId": "mcr",
+            "mainStep": true,
+            "type": "DECLARER_DEMANDE_FAVORABLE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "mim",
+            "mainStep": false,
+            "type": "DEMANDER_INFORMATION_MCR",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "mod",
+            "mainStep": false,
+            "type": "MODIFIER_DEMANDE",
+          },
+        ],
+      }
     `)
   })
   test('après une recevabilité défavorable on doit avoir un avis des services et commissions consultatives', () => {
@@ -684,36 +696,39 @@ describe('mainStep', () => {
         toCaminoDate('2021-02-03')
       )
     ).toMatchInlineSnapshot(`
-      [
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "asc",
-          "mainStep": true,
-          "type": "RENDRE_AVIS_DES_SERVICES_ET_COMMISSIONS_CONSULTATIVES",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "css",
-          "mainStep": false,
-          "type": "CLASSER_SANS_SUITE",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "des",
-          "mainStep": false,
-          "type": "DESISTER_PAR_LE_DEMANDEUR",
-        },
-        {
-          "contenu": undefined,
-          "etapeStatutId": "fai",
-          "etapeTypeId": "mod",
-          "mainStep": false,
-          "type": "MODIFIER_DEMANDE",
-        },
-      ]
+      {
+        "valid": true,
+        "value": [
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "asc",
+            "mainStep": true,
+            "type": "RENDRE_AVIS_DES_SERVICES_ET_COMMISSIONS_CONSULTATIVES",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "css",
+            "mainStep": false,
+            "type": "CLASSER_SANS_SUITE",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "des",
+            "mainStep": false,
+            "type": "DESISTER_PAR_LE_DEMANDEUR",
+          },
+          {
+            "contenu": undefined,
+            "etapeStatutId": "fai",
+            "etapeTypeId": "mod",
+            "mainStep": false,
+            "type": "MODIFIER_DEMANDE",
+          },
+        ],
+      }
     `)
   })
 })
diff --git a/packages/api/src/business/rules-demarches/machine-helper.ts b/packages/api/src/business/rules-demarches/machine-helper.ts
index 9142bd5c2..c4c604acb 100644
--- a/packages/api/src/business/rules-demarches/machine-helper.ts
+++ b/packages/api/src/business/rules-demarches/machine-helper.ts
@@ -186,7 +186,7 @@ export abstract class CaminoMachine<CaminoContext extends CaminoCommonContext, C
   }
 
   private goTo(etapes: readonly Etape[]):
-    | { valid: false; etapeIndex: number }
+    | { valid: false; etapeIndex: number; error: string }
     | {
         valid: true
         state: CaminoState<CaminoContext, CaminoEvent>
@@ -208,7 +208,7 @@ export abstract class CaminoMachine<CaminoContext extends CaminoCommonContext, C
         if (!service.getSnapshot().can(event) || service.getSnapshot().status === 'done') {
           service.stop()
 
-          return { valid: false, etapeIndex: i }
+          return { valid: false, etapeIndex: i, error: `Les étapes '${JSON.stringify(etapes)}' sont invalides à partir de l’étape ${i}` }
         }
         service.send(event)
       }
@@ -286,21 +286,20 @@ export abstract class CaminoMachine<CaminoContext extends CaminoCommonContext, C
     }
   }
 
-  private assertGoTo(etapes: readonly Etape[]): CaminoState<CaminoContext, CaminoEvent> {
-    const value = this.goTo(etapes)
-    if (!value.valid) {
-      throw new Error(`Les étapes '${JSON.stringify(etapes)}' sont invalides à partir de l’étape ${value.etapeIndex}`)
-    } else {
-      return value.state
+  public whoIsBlocking(etapes: readonly Etape[]):
+    | { valid: false; etapeIndex: number; error: string }
+    | {
+        valid: true
+        value: Intervenant[]
+      } {
+    const state = this.goTo(etapes)
+    if (!state.valid) {
+      return state
     }
-  }
-
-  public whoIsBlocking(etapes: readonly Etape[]): Intervenant[] {
-    const state = this.assertGoTo(etapes)
 
-    const responsables: string[] = [...state.tags]
+    const responsables: string[] = [...state.state.tags]
 
-    return intervenants.filter(r => responsables.includes(tags.responsable[r]))
+    return { valid: true, value: intervenants.filter(r => responsables.includes(tags.responsable[r])) }
   }
 
   // visibleForTesting
@@ -318,17 +317,29 @@ export abstract class CaminoMachine<CaminoContext extends CaminoCommonContext, C
       .toSorted((a, b) => a.type.localeCompare(b.type))
   }
 
-  public possibleNextEtapes(etapes: readonly Etape[], date: CaminoDate): (OmitDistributive<Etape, 'date' | 'titreTypeId' | 'demarcheTypeId'> & { mainStep: boolean; type: CaminoEvent['type'] })[] {
-    const state = this.assertGoTo(etapes)
+  public possibleNextEtapes(
+    etapes: readonly Etape[],
+    date: CaminoDate
+  ):
+    | { valid: false; etapeIndex: number; error: string }
+    | { valid: true; value: (OmitDistributive<Etape, 'date' | 'titreTypeId' | 'demarcheTypeId'> & { mainStep: boolean; type: CaminoEvent['type'] })[] } {
+    const state = this.goTo(etapes)
+
+    if (!state.valid) {
+      return state
+    }
 
     if (isNotNullNorUndefined(state)) {
-      return this.possibleNextEvents(state, date)
-        .flatMap(this.caminoXStateEventToEtapes.bind(this))
-        .filter(isNotNullNorUndefined)
-        .toSorted((a, b) => a.etapeTypeId.localeCompare(b.etapeTypeId))
+      return {
+        valid: true,
+        value: this.possibleNextEvents(state.state, date)
+          .flatMap(this.caminoXStateEventToEtapes.bind(this))
+          .filter(isNotNullNorUndefined)
+          .toSorted((a, b) => a.etapeTypeId.localeCompare(b.etapeTypeId)),
+      }
     }
 
-    return []
+    return { valid: true, value: [] }
   }
 }
 
diff --git a/packages/api/src/business/rules-demarches/procedure-specifique/procedure-specifique.machine.test.ts b/packages/api/src/business/rules-demarches/procedure-specifique/procedure-specifique.machine.test.ts
index cfa13d081..9ef758337 100644
--- a/packages/api/src/business/rules-demarches/procedure-specifique/procedure-specifique.machine.test.ts
+++ b/packages/api/src/business/rules-demarches/procedure-specifique/procedure-specifique.machine.test.ts
@@ -676,7 +676,7 @@ describe('vérifie l’arbre des procédures spécifique', () => {
       ETES.decisionDeLAutoriteAdministrative.REJETE_DECISION_IMPLICITE,
     ])
 
-    expect(machine.possibleNextEtapes(etapes, dateFin)).toStrictEqual([])
+    expect(machine.possibleNextEtapes(etapes, dateFin)).toStrictEqual({ valid: true, value: [] })
     expect(machine.demarcheStatut(etapes)).toMatchInlineSnapshot(`
        {
          "demarcheDateDebut": {
@@ -710,7 +710,7 @@ describe('vérifie l’arbre des procédures spécifique', () => {
       ETES.decisionDeLAutoriteAdministrative.REJETE_DECISION_IMPLICITE,
     ])
 
-    expect(machine.possibleNextEtapes(etapes, dateFin)).toStrictEqual([])
+    expect(machine.possibleNextEtapes(etapes, dateFin)).toStrictEqual({ valid: true, value: [] })
     expect(machine.demarcheStatut(etapes)).toMatchInlineSnapshot(`
       {
         "demarcheDateDebut": {
diff --git a/packages/api/src/business/validations/titre-demarche-etat-validate.test.ts b/packages/api/src/business/validations/titre-demarche-etat-validate.test.ts
index 16e21079b..5d4d48f48 100644
--- a/packages/api/src/business/validations/titre-demarche-etat-validate.test.ts
+++ b/packages/api/src/business/validations/titre-demarche-etat-validate.test.ts
@@ -806,6 +806,230 @@ describe('getPossiblesEtapesTypes', () => {
     `)
   })
 
+  test("peut déplacer la saisineDuPrefet qui est le même jour que d'autres", () => {
+    const sppId = newEtapeId('spp')
+    const etapes: TitreEtapeForMachine[] = [
+      {
+        typeId: ETAPES_TYPES.demande,
+        date: toCaminoDate('2023-02-28'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('demandeId'),
+        ordre: 1,
+        statutId: 'fai',
+        communes: [{ id: toCommuneId('64012') }],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.avisDeMiseEnConcurrenceAuJORF,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('anf'),
+        ordre: 5,
+        statutId: 'ter',
+        communes: [{ id: toCommuneId('64012') }],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.enregistrementDeLaDemande,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('men'),
+        ordre: 2,
+        statutId: 'fai',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.saisineDuPrefet,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: sppId,
+        ordre: 3,
+        statutId: 'fai',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.recevabiliteDeLaDemande,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('mcr'),
+        ordre: 4,
+        statutId: 'fav',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+    ]
+
+    expect(
+      getPossiblesEtapesTypes(
+        new PrmOctMachine(TITRES_TYPES_IDS.PERMIS_EXCLUSIF_DE_RECHERCHES_METAUX, DEMARCHES_TYPES_IDS.Octroi),
+        TITRES_TYPES_IDS.PERMIS_EXCLUSIF_DE_RECHERCHES_METAUX,
+        DEMARCHES_TYPES_IDS.Octroi,
+        ETAPES_TYPES.saisineDuPrefet,
+        sppId,
+        toCaminoDate('2023-05-03'),
+        etapes
+      )
+    ).toMatchInlineSnapshot(`
+      {
+        "spp": {
+          "etapeStatutIds": [
+            "fai",
+          ],
+          "mainStep": true,
+        },
+      }
+    `)
+  })
+
+  test("peut déplacer l'enregistrement de la demande le même jour", () => {
+    const menId = newEtapeId('menId')
+    const etapes: TitreEtapeForMachine[] = [
+      {
+        typeId: ETAPES_TYPES.demande,
+        date: toCaminoDate('2023-02-28'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('demandeId'),
+        ordre: 1,
+        statutId: 'fai',
+        communes: [{ id: toCommuneId('64012') }],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.avisDeMiseEnConcurrenceAuJORF,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('anf'),
+        ordre: 5,
+        statutId: 'ter',
+        communes: [{ id: toCommuneId('64012') }],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.enregistrementDeLaDemande,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: menId,
+        ordre: 2,
+        statutId: 'fai',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.saisineDuPrefet,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('spp'),
+        ordre: 3,
+        statutId: 'fai',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.recevabiliteDeLaDemande,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('mcr'),
+        ordre: 4,
+        statutId: 'fav',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+    ]
+
+    expect(
+      getPossiblesEtapesTypes(
+        new PrmOctMachine(TITRES_TYPES_IDS.PERMIS_EXCLUSIF_DE_RECHERCHES_METAUX, DEMARCHES_TYPES_IDS.Octroi),
+        TITRES_TYPES_IDS.PERMIS_EXCLUSIF_DE_RECHERCHES_METAUX,
+        DEMARCHES_TYPES_IDS.Octroi,
+        ETAPES_TYPES.enregistrementDeLaDemande,
+        menId,
+        toCaminoDate('2023-05-03'),
+        etapes
+      )
+    ).toMatchInlineSnapshot(`
+      {
+        "men": {
+          "etapeStatutIds": [
+            "fai",
+          ],
+          "mainStep": true,
+        },
+      }
+    `)
+  })
+
+  test("ne peut pas déplacer l'enregistrement de la demande après les autres étapes", () => {
+    const menId = newEtapeId('menId')
+    const etapes: TitreEtapeForMachine[] = [
+      {
+        typeId: ETAPES_TYPES.demande,
+        date: toCaminoDate('2023-02-28'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('demandeId'),
+        ordre: 1,
+        statutId: 'fai',
+        communes: [{ id: toCommuneId('64012') }],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.avisDeMiseEnConcurrenceAuJORF,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('anf'),
+        ordre: 5,
+        statutId: 'ter',
+        communes: [{ id: toCommuneId('64012') }],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.enregistrementDeLaDemande,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: menId,
+        ordre: 2,
+        statutId: 'fai',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.saisineDuPrefet,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('spp'),
+        ordre: 3,
+        statutId: 'fai',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+      {
+        typeId: ETAPES_TYPES.recevabiliteDeLaDemande,
+        date: toCaminoDate('2023-05-03'),
+        isBrouillon: ETAPE_IS_NOT_BROUILLON,
+        id: newEtapeId('mcr'),
+        ordre: 4,
+        statutId: 'fav',
+        communes: [],
+        demarcheIdsConsentement: [],
+      },
+    ]
+
+    expect(
+      getPossiblesEtapesTypes(
+        new PrmOctMachine(TITRES_TYPES_IDS.PERMIS_EXCLUSIF_DE_RECHERCHES_METAUX, DEMARCHES_TYPES_IDS.Octroi),
+        TITRES_TYPES_IDS.PERMIS_EXCLUSIF_DE_RECHERCHES_METAUX,
+        DEMARCHES_TYPES_IDS.Octroi,
+        ETAPES_TYPES.enregistrementDeLaDemande,
+        menId,
+        toCaminoDate('2023-06-01'),
+        etapes
+      )
+    ).toMatchInlineSnapshot(`
+      {}
+    `)
+  })
+
   test('peut créer une étape sur une procédure spécifique vide', () => {
     expect(getPossiblesEtapesTypes(new ProcedureSpecifiqueMachine('cxm', 'oct'), 'cxm', 'oct', undefined, undefined, toCaminoDate('4000-02-01'), [])).toMatchInlineSnapshot(`
       {
diff --git a/packages/api/src/business/validations/titre-demarche-etat-validate.ts b/packages/api/src/business/validations/titre-demarche-etat-validate.ts
index df69a3fec..611b6d24b 100644
--- a/packages/api/src/business/validations/titre-demarche-etat-validate.ts
+++ b/packages/api/src/business/validations/titre-demarche-etat-validate.ts
@@ -192,25 +192,36 @@ export const getPossiblesEtapesTypes = (
 const etapesTypesPossibleACetteDateOuALaPlaceDeLEtape = (machine: CaminoMachines, etapes: TitreEtapeForMachine[], titreEtapeId: string | null, date: CaminoDate): EtapeTypeEtapeStatutWithMainStep => {
   const sortedEtapes = titreEtapesSortAscByOrdre(etapes).filter(etape => etape.id !== titreEtapeId)
   const etapesAvant: Etape[] = []
+  const etapesPendant: Etape[] = []
+
   const etapesApres: Etape[] = []
 
-  // TODO 2022-07-12: Il faudrait mieux gérer les étapes à la même date que l'étape qu'on veut rajouter
-  // elles ne sont ni avant, ni après, mais potentiellement au milieu de toutes ces étapes
-  // UPDATE 2025-03-31: il n'y en a pas en prod
-  etapesAvant.push(...toMachineEtapes(sortedEtapes.filter(etape => etape.date <= date)))
-  etapesApres.push(...toMachineEtapes(sortedEtapes.slice(etapesAvant.length)))
+  etapesAvant.push(...toMachineEtapes(sortedEtapes.filter(etape => etape.date < date)))
+  etapesPendant.push(...toMachineEtapes(sortedEtapes.filter(etape => etape.date === date)))
+  etapesApres.push(...toMachineEtapes(sortedEtapes.slice(etapesAvant.length + etapesPendant.length)))
 
-  const etapesPossiblesRaw = machine.possibleNextEtapes(etapesAvant, date)
+  if (!machine.isEtapesOk(etapesAvant)) {
+    return {}
+  }
   const etapesPossibles = []
-  for (const et of etapesPossiblesRaw) {
-    const newEtapes = [...etapesAvant]
 
-    const items = { ...et, date }
-    newEtapes.push(items)
-    newEtapes.push(...etapesApres)
+  for (let i = 0; i <= etapesPendant.length; i++) {
+    const etapeEnCours = [...etapesAvant, ...etapesPendant.slice(0, i)]
+    const etapesPossiblesRaw = machine.possibleNextEtapes(etapeEnCours, date)
+    if (etapesPossiblesRaw.valid) {
+      for (const et of etapesPossiblesRaw.value) {
+        const newEtapes = [...etapeEnCours]
+
+        const items = { ...et, date }
+        newEtapes.push(items)
+        newEtapes.push(...etapesPendant.slice(i))
 
-    if (machine.isEtapesOk(newEtapes)) {
-      etapesPossibles.push(et)
+        newEtapes.push(...etapesApres)
+
+        if (machine.isEtapesOk(newEtapes)) {
+          etapesPossibles.push(et)
+        }
+      }
     }
   }
 
diff --git a/packages/api/src/tools/fp-tools.ts b/packages/api/src/tools/fp-tools.ts
index b670998c5..73202de02 100644
--- a/packages/api/src/tools/fp-tools.ts
+++ b/packages/api/src/tools/fp-tools.ts
@@ -1,3 +1,4 @@
+import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools'
 import { CaminoError, CaminoZodErrorReadableMessage, translateIssue } from 'camino-common/src/zod-tools'
 import { Cause, Effect, Exit, pipe } from 'effect'
 import { ZodTypeAny } from 'zod'
@@ -36,10 +37,10 @@ export const zodParseEffect = <T extends ZodTypeAny>(validator: T, item: unknown
   })
 }
 
-export const zodParseEffectTyped = <T extends ZodTypeAny, U extends string>(validator: T, item: T['_output'], errorMessage: U): Effect.Effect<T['_output'], CaminoError<U>> => {
+export const zodParseEffectTyped = <T extends ZodTypeAny, U extends string>(validator: T, item: T['_output'], errorMessage: U, detail?: string): Effect.Effect<T['_output'], CaminoError<U>> => {
   return Effect.try({
     try: () => validator.parse(item),
-    catch: myError => ({ message: errorMessage, detail: zodErrorToDetail(myError), zodErrorReadableMessage: zodErrorToReadableMessage(myError) }),
+    catch: myError => ({ message: errorMessage, detail: isNotNullNorUndefined(detail) ? detail : zodErrorToDetail(myError), zodErrorReadableMessage: zodErrorToReadableMessage(myError) }),
   })
 }
 
-- 
GitLab