From 18d7653516596d338d0e6ffd2177a5f88e710f12 Mon Sep 17 00:00:00 2001
From: Marc Rutkowski <marc@attractive-media.fr>
Date: Tue, 7 Jan 2025 15:13:08 +0100
Subject: [PATCH 1/6] =?UTF-8?q?Ajoute=20l'entr=C3=A9e=20tRPC=20de=20lectur?=
 =?UTF-8?q?e/m=C3=A0j=20des=20valeurs=20d'indicateur?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 backend/src/indicateurs/indicateurs.module.ts |   3 +
 .../valeurs/crud-valeurs.router.e2e-spec.ts   | 192 ++++++++++++++++++
 .../valeurs/crud-valeurs.router.ts            |  26 +++
 backend/src/utils/trpc/trpc.router.ts         |   3 +
 4 files changed, 224 insertions(+)
 create mode 100644 backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
 create mode 100644 backend/src/indicateurs/valeurs/crud-valeurs.router.ts

diff --git a/backend/src/indicateurs/indicateurs.module.ts b/backend/src/indicateurs/indicateurs.module.ts
index f4b0413253..64408c1bb7 100644
--- a/backend/src/indicateurs/indicateurs.module.ts
+++ b/backend/src/indicateurs/indicateurs.module.ts
@@ -14,6 +14,7 @@ import TrajectoiresXlsxService from './trajectoires/trajectoires-xlsx.service';
 import { TrajectoiresController } from './trajectoires/trajectoires.controller';
 import { TrajectoiresRouter } from './trajectoires/trajectoires.router';
 import { IndicateursController } from './valeurs/crud-valeurs.controller';
+import { IndicateurValeursRouter } from './valeurs/crud-valeurs.router';
 import CrudValeursService from './valeurs/crud-valeurs.service';
 
 @Module({
@@ -25,6 +26,7 @@ import CrudValeursService from './valeurs/crud-valeurs.service';
     CrudValeursService,
     IndicateurFiltreService,
     IndicateurFiltreRouter,
+    IndicateurValeursRouter,
     TrajectoiresDataService,
     TrajectoiresSpreadsheetService,
     TrajectoiresXlsxService,
@@ -37,6 +39,7 @@ import CrudValeursService from './valeurs/crud-valeurs.service';
     TrajectoiresRouter,
     IndicateurFiltreService,
     IndicateurFiltreRouter,
+    IndicateurValeursRouter,
   ],
   controllers: [
     IndicateursController,
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts b/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
new file mode 100644
index 0000000000..43ff3e1695
--- /dev/null
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
@@ -0,0 +1,192 @@
+import { INestApplication } from '@nestjs/common';
+import { inferProcedureInput } from '@trpc/server';
+import { and, eq } from 'drizzle-orm';
+import { getTestApp } from '../../../test/app-utils';
+import { getAuthUser } from '../../../test/auth-utils';
+import { AuthenticatedUser } from '../../auth/models/auth.models';
+import { DatabaseService } from '../../utils';
+import { AppRouter, TrpcRouter } from '../../utils/trpc/trpc.router';
+import { indicateurValeurTable } from '../index-domain';
+import { getIndicateursValeursResponseSchema } from '../shared/models/get-indicateurs.response';
+
+type InputList = inferProcedureInput<
+  AppRouter['indicateurs']['valeurs']['list']
+>;
+
+type InputUpsert = inferProcedureInput<
+  AppRouter['indicateurs']['valeurs']['upsert']
+>;
+
+const collectiviteId = 1;
+const indicateurId = 1;
+
+describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
+  let app: INestApplication;
+  let router: TrpcRouter;
+  let yoloDodoUser: AuthenticatedUser;
+  let databaseService: DatabaseService;
+
+  beforeAll(async () => {
+    app = await getTestApp();
+    router = app.get(TrpcRouter);
+    yoloDodoUser = await getAuthUser();
+
+    // reset les données avant de commencer les tests
+    databaseService = app.get<DatabaseService>(DatabaseService);
+    await databaseService.db
+      .delete(indicateurValeurTable)
+      .where(
+        and(
+          eq(indicateurValeurTable.collectiviteId, collectiviteId),
+          eq(indicateurValeurTable.indicateurId, indicateurId)
+        )
+      );
+  });
+
+  test(`Renvoi des valeurs`, async () => {
+    const caller = router.createCaller({ user: yoloDodoUser });
+
+    const input: InputList = {
+      collectiviteId,
+      identifiantsReferentiel: 'cae_8',
+    };
+    const result = await caller.indicateurs.valeurs.list(input);
+    expect(result.indicateurs.length).not.toBe(0);
+    expect(result.indicateurs[0].sources.collectivite.valeurs.length).not.toBe(
+      0
+    );
+    const toCheck = getIndicateursValeursResponseSchema.safeParse(result);
+    expect(toCheck.success).toBeTruthy;
+  });
+
+  test("Permet d'insérer une valeur", async () => {
+    const caller = router.createCaller({ user: yoloDodoUser });
+
+    // vérifie le nombre de valeurs avant insertion
+    const inputBefore: InputList = {
+      collectiviteId,
+      indicateurIds: [indicateurId],
+    };
+    const resultBefore = await caller.indicateurs.valeurs.list(inputBefore);
+    expect(resultBefore.indicateurs.length).toBe(0);
+
+    // insère une valeur
+    const input: InputUpsert = {
+      valeurs: [
+        {
+          collectiviteId,
+          indicateurId,
+          dateValeur: '2021-01-01',
+          resultat: 42,
+        },
+      ],
+    };
+    const result = await caller.indicateurs.valeurs.upsert(input);
+    expect(result).not.toBe(null);
+
+    // vérifie le nombre de valeurs après insertion
+    const resultAfter = await caller.indicateurs.valeurs.list(inputBefore);
+    expect(resultAfter.indicateurs[0].sources.collectivite.valeurs.length).toBe(
+      1
+    );
+  });
+
+  test('Permet de mettre à jour une valeur', async () => {
+    const caller = router.createCaller({ user: yoloDodoUser });
+
+    // insère une valeur
+    const inputInsert: InputUpsert = {
+      valeurs: [
+        {
+          collectiviteId,
+          indicateurId,
+          dateValeur: '2021-01-01',
+          resultat: 42,
+        },
+      ],
+    };
+    await caller.indicateurs.valeurs.upsert(inputInsert);
+
+    // vérifie le nombre de valeurs avant mise à jour
+    const inputBefore: InputList = {
+      collectiviteId,
+      indicateurIds: [indicateurId],
+    };
+    const resultBefore = await caller.indicateurs.valeurs.list(inputBefore);
+    expect(
+      resultBefore.indicateurs[0].sources.collectivite.valeurs.length
+    ).toBe(1);
+
+    // met à jour la valeur
+    const inputUpdate: InputUpsert = {
+      valeurs: [
+        {
+          collectiviteId,
+          indicateurId,
+          dateValeur: '2021-01-01',
+          resultat: 43,
+        },
+      ],
+    };
+    const result = await caller.indicateurs.valeurs.upsert(inputUpdate);
+    expect(result).not.toBe(null);
+
+    // vérifie le nombre de valeurs après mise à jour
+    const resultAfter = await caller.indicateurs.valeurs.list(inputBefore);
+    expect(resultAfter.indicateurs[0].sources.collectivite.valeurs.length).toBe(
+      1
+    );
+    expect(
+      resultAfter.indicateurs[0].sources.collectivite.valeurs[0].resultat
+    ).toBe(43);
+
+    // met à jour la valeur objectif pour la même date
+    const inputUpdateObjectif: InputUpsert = {
+      valeurs: [
+        {
+          collectiviteId,
+          indicateurId,
+          dateValeur: '2021-01-01',
+          resultat: 43, // il faut renvoyer aussi le résultat sinon il est effacé
+          objectif: 44,
+        },
+      ],
+    };
+    const resultObjectif = await caller.indicateurs.valeurs.upsert(
+      inputUpdateObjectif
+    );
+    expect(resultObjectif).not.toBe(null);
+
+    // vérifie le nombre de valeurs après mise à jour
+    const resultAfterObjectif = await caller.indicateurs.valeurs.list(
+      inputBefore
+    );
+    expect(
+      resultAfterObjectif.indicateurs[0].sources.collectivite.valeurs.length
+    ).toBe(1);
+    expect(
+      resultAfterObjectif.indicateurs[0].sources.collectivite.valeurs[0]
+        .resultat
+    ).toBe(43);
+    expect(
+      resultAfterObjectif.indicateurs[0].sources.collectivite.valeurs[0]
+        .objectif
+    ).toBe(44);
+  });
+
+  test("Ne permet pas d'insérer une valeur si on n'a pas le droit requis", async () => {
+    const caller = router.createCaller({ user: yoloDodoUser });
+
+    const input: InputUpsert = {
+      valeurs: [
+        {
+          collectiviteId: 100,
+          indicateurId: 1,
+          dateValeur: '2021-01-01',
+          resultat: 42,
+        },
+      ],
+    };
+    await expect(caller.indicateurs.valeurs.upsert(input)).rejects.toThrow();
+  });
+});
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.router.ts b/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
new file mode 100644
index 0000000000..b15263dab5
--- /dev/null
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
@@ -0,0 +1,26 @@
+import { TrpcService } from '@/backend/utils/trpc/trpc.service';
+import { Injectable } from '@nestjs/common';
+import { getIndicateursValeursRequestSchema } from '../shared/models/get-indicateurs.request';
+import { upsertIndicateursValeursRequestSchema } from '../shared/models/upsert-indicateurs-valeurs.request';
+import IndicateurValeursService from './crud-valeurs.service';
+
+@Injectable()
+export class IndicateurValeursRouter {
+  constructor(
+    private readonly trpc: TrpcService,
+    private readonly service: IndicateurValeursService
+  ) {}
+
+  router = this.trpc.router({
+    list: this.trpc.authedProcedure
+      .input(getIndicateursValeursRequestSchema)
+      .query(({ ctx, input }) => {
+        return this.service.getIndicateurValeursGroupees(input, ctx.user);
+      }),
+    upsert: this.trpc.authedProcedure
+      .input(upsertIndicateursValeursRequestSchema)
+      .mutation(({ input, ctx }) => {
+        return this.service.upsertIndicateurValeurs(input.valeurs, ctx.user);
+      }),
+  });
+}
diff --git a/backend/src/utils/trpc/trpc.router.ts b/backend/src/utils/trpc/trpc.router.ts
index 2a8e372aa9..6859f96783 100644
--- a/backend/src/utils/trpc/trpc.router.ts
+++ b/backend/src/utils/trpc/trpc.router.ts
@@ -8,6 +8,7 @@ import { CollectiviteMembresRouter } from '../../collectivites/membres/membres.r
 import { PersonnesRouter } from '../../collectivites/personnes.router';
 import { IndicateurFiltreRouter } from '../../indicateurs/definitions/indicateur-filtre.router';
 import { TrajectoiresRouter } from '../../indicateurs/trajectoires/trajectoires.router';
+import { IndicateurValeursRouter } from '../../indicateurs/valeurs/crud-valeurs.router';
 import { ComputeScoreRouter } from '../../referentiels/compute-score/compute-score.router';
 import { ScoreSnapshotsRouter } from '../../referentiels/snapshots/score-snaphots.router';
 import { UpdateActionStatutRouter } from '../../referentiels/update-action-statut/update-action-statut.router';
@@ -27,6 +28,7 @@ export class TrpcRouter {
     private readonly personnes: PersonnesRouter,
     private readonly ficheActionEtapeRouter: FicheActionEtapeRouter,
     private readonly indicateurFiltreRouter: IndicateurFiltreRouter,
+    private readonly indicateurValeursRouter: IndicateurValeursRouter,
     private readonly bulkEditRouter: BulkEditRouter,
     private readonly membresRouter: CollectiviteMembresRouter,
     private readonly updateActionStatutRouter: UpdateActionStatutRouter,
@@ -38,6 +40,7 @@ export class TrpcRouter {
     indicateurs: {
       trajectoires: this.trajectoiresRouter.router,
       list: this.indicateurFiltreRouter.router.list,
+      valeurs: this.indicateurValeursRouter.router,
     },
     plans: {
       fiches: this.trpc.mergeRouters(

From e56d51ec1eb0bf26f841b9d3414279f6c38a2d4d Mon Sep 17 00:00:00 2001
From: Marc Rutkowski <marc@attractive-media.fr>
Date: Wed, 15 Jan 2025 10:38:26 +0100
Subject: [PATCH 2/6] Ajoute une nouvelle fonction d'upsert d'une valeur
 d'indicateur

---
 .../shared/models/indicateur-valeur.table.ts  |  1 +
 .../upsert-valeur-indicateur.request.ts       | 17 +++++
 .../valeurs/crud-valeurs.router.e2e-spec.ts   | 73 ++++++++-----------
 .../valeurs/crud-valeurs.router.ts            |  7 +-
 .../valeurs/crud-valeurs.service.ts           | 63 ++++++++++++++++
 5 files changed, 115 insertions(+), 46 deletions(-)
 create mode 100644 backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts

diff --git a/backend/src/indicateurs/shared/models/indicateur-valeur.table.ts b/backend/src/indicateurs/shared/models/indicateur-valeur.table.ts
index 79e1c50e51..0cad910e5c 100644
--- a/backend/src/indicateurs/shared/models/indicateur-valeur.table.ts
+++ b/backend/src/indicateurs/shared/models/indicateur-valeur.table.ts
@@ -112,3 +112,4 @@ export interface IndicateurValeurAvecMetadonnesDefinition {
 
   indicateur_source_metadonnee: SourceMetadonnee | null;
 }
+
diff --git a/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts b/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
new file mode 100644
index 0000000000..d905979910
--- /dev/null
+++ b/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
@@ -0,0 +1,17 @@
+import { z } from 'zod';
+
+/** Upsert d'une valeur d'indicateur pour une collectivité */
+export const upsertValeurIndicateurSchema = z.object({
+  collectiviteId: z.number(),
+  indicateurId: z.number(),
+  id: z.number().optional(), // pour un update
+  dateValeur: z.string().optional(), // pour un insert
+  resultat: z.number().nullish(),
+  resultatCommentaire: z.string().nullish(),
+  objectif: z.number().nullish(),
+  objectifCommentaire: z.string().nullish(),
+});
+
+export type UpsertValeurIndicateur = z.infer<
+  typeof upsertValeurIndicateurSchema
+>;
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts b/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
index 43ff3e1695..164355bcef 100644
--- a/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
@@ -30,9 +30,11 @@ describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
     app = await getTestApp();
     router = app.get(TrpcRouter);
     yoloDodoUser = await getAuthUser();
+    databaseService = app.get<DatabaseService>(DatabaseService);
+  });
 
+  beforeEach(async () => {
     // reset les données avant de commencer les tests
-    databaseService = app.get<DatabaseService>(DatabaseService);
     await databaseService.db
       .delete(indicateurValeurTable)
       .where(
@@ -72,14 +74,11 @@ describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
 
     // insère une valeur
     const input: InputUpsert = {
-      valeurs: [
-        {
-          collectiviteId,
-          indicateurId,
-          dateValeur: '2021-01-01',
-          resultat: 42,
-        },
-      ],
+      collectiviteId,
+      indicateurId,
+      dateValeur: '2021-01-01',
+      resultat: 42,
+      resultatCommentaire: 'commentaire',
     };
     const result = await caller.indicateurs.valeurs.upsert(input);
     expect(result).not.toBe(null);
@@ -96,14 +95,11 @@ describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
 
     // insère une valeur
     const inputInsert: InputUpsert = {
-      valeurs: [
-        {
-          collectiviteId,
-          indicateurId,
-          dateValeur: '2021-01-01',
-          resultat: 42,
-        },
-      ],
+      collectiviteId,
+      indicateurId,
+      dateValeur: '2021-01-01',
+      resultat: 42,
+      resultatCommentaire: 'commentaire',
     };
     await caller.indicateurs.valeurs.upsert(inputInsert);
 
@@ -117,16 +113,12 @@ describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
       resultBefore.indicateurs[0].sources.collectivite.valeurs.length
     ).toBe(1);
 
-    // met à jour la valeur
+    // met à jour que la valeur
     const inputUpdate: InputUpsert = {
-      valeurs: [
-        {
-          collectiviteId,
-          indicateurId,
-          dateValeur: '2021-01-01',
-          resultat: 43,
-        },
-      ],
+      collectiviteId,
+      indicateurId,
+      id: resultBefore.indicateurs[0].sources.collectivite.valeurs[0].id,
+      resultat: 43,
     };
     const result = await caller.indicateurs.valeurs.upsert(inputUpdate);
     expect(result).not.toBe(null);
@@ -139,18 +131,17 @@ describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
     expect(
       resultAfter.indicateurs[0].sources.collectivite.valeurs[0].resultat
     ).toBe(43);
+    expect(
+      resultAfter.indicateurs[0].sources.collectivite.valeurs[0]
+        .resultatCommentaire
+    ).toBe('commentaire');
 
     // met à jour la valeur objectif pour la même date
     const inputUpdateObjectif: InputUpsert = {
-      valeurs: [
-        {
-          collectiviteId,
-          indicateurId,
-          dateValeur: '2021-01-01',
-          resultat: 43, // il faut renvoyer aussi le résultat sinon il est effacé
-          objectif: 44,
-        },
-      ],
+      collectiviteId,
+      indicateurId,
+      id: resultBefore.indicateurs[0].sources.collectivite.valeurs[0].id,
+      objectif: 44,
     };
     const resultObjectif = await caller.indicateurs.valeurs.upsert(
       inputUpdateObjectif
@@ -178,14 +169,10 @@ describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
     const caller = router.createCaller({ user: yoloDodoUser });
 
     const input: InputUpsert = {
-      valeurs: [
-        {
-          collectiviteId: 100,
-          indicateurId: 1,
-          dateValeur: '2021-01-01',
-          resultat: 42,
-        },
-      ],
+      collectiviteId: 100,
+      indicateurId: 1,
+      dateValeur: '2021-01-01',
+      resultat: 42,
     };
     await expect(caller.indicateurs.valeurs.upsert(input)).rejects.toThrow();
   });
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.router.ts b/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
index b15263dab5..05603d987c 100644
--- a/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
@@ -1,7 +1,8 @@
 import { TrpcService } from '@/backend/utils/trpc/trpc.service';
 import { Injectable } from '@nestjs/common';
+import { upsertValeurIndicateurSchema } from '../index-domain';
 import { getIndicateursValeursRequestSchema } from '../shared/models/get-indicateurs.request';
-import { upsertIndicateursValeursRequestSchema } from '../shared/models/upsert-indicateurs-valeurs.request';
+import { upsertValeurIndicateurSchema } from '../shared/models/upsert-valeur-indicateur.request';
 import IndicateurValeursService from './crud-valeurs.service';
 
 @Injectable()
@@ -18,9 +19,9 @@ export class IndicateurValeursRouter {
         return this.service.getIndicateurValeursGroupees(input, ctx.user);
       }),
     upsert: this.trpc.authedProcedure
-      .input(upsertIndicateursValeursRequestSchema)
+      .input(upsertValeurIndicateurSchema)
       .mutation(({ input, ctx }) => {
-        return this.service.upsertIndicateurValeurs(input.valeurs, ctx.user);
+        return this.service.upsertValeur(input, ctx.user);
       }),
   });
 }
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.service.ts b/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
index df75c0a60a..59fa6ec8ab 100644
--- a/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
@@ -43,6 +43,7 @@ import {
   indicateurValeursGroupeeParSourceSchema,
   indicateurValeurTable,
 } from '../shared/models/indicateur-valeur.table';
+import { UpsertValeurIndicateur } from '../shared/models/upsert-valeur-indicateur.request';
 
 export class IndicateurValeurGroupee extends createZodDto(
   extendApi(indicateurValeurGroupeeSchema)
@@ -305,6 +306,68 @@ export default class CrudValeursService {
     return definitions;
   }
 
+  async upsertValeur(
+    data: UpsertValeurIndicateur,
+    tokenInfo: AuthenticatedUser
+  ) {
+    const { collectiviteId } = data;
+    await this.permissionService.isAllowed(
+      tokenInfo,
+      PermissionOperation.INDICATEURS_EDITION,
+      ResourceType.COLLECTIVITE,
+      collectiviteId
+    );
+
+    if (tokenInfo.role === AuthRole.AUTHENTICATED && tokenInfo.id) {
+      const now = new Date().toISOString();
+      if (data.id !== undefined) {
+        this.logger.log(
+          `Mise à jour de la valeur id ${data.id} pour la collectivité ${data.collectiviteId}`
+        );
+        const updated = await this.databaseService.db
+          .update(indicateurValeurTable)
+          .set({
+            resultat: data.resultat,
+            resultatCommentaire: data.resultatCommentaire,
+            objectif: data.objectif,
+            objectifCommentaire: data.objectifCommentaire,
+            modifiedBy: tokenInfo.id,
+            modifiedAt: now,
+          })
+          .where(
+            and(
+              eq(indicateurValeurTable.collectiviteId, collectiviteId),
+              eq(indicateurValeurTable.id, data.id)
+            )
+          )
+          .returning();
+        return updated[0];
+      }
+
+      if (data.dateValeur !== undefined) {
+        this.logger.log(
+          `Insertion de la valeur de l'indicateur ${data.indicateurId} pour la collectivité ${data.collectiviteId}`
+        );
+        const inserted = await this.databaseService.db
+          .insert(indicateurValeurTable)
+          .values({
+            collectiviteId,
+            indicateurId: data.indicateurId,
+            dateValeur: data.dateValeur,
+            resultat: data.resultat,
+            resultatCommentaire: data.resultatCommentaire,
+            objectif: data.objectif,
+            objectifCommentaire: data.objectifCommentaire,
+            createdBy: tokenInfo.id,
+            createdAt: now,
+            modifiedBy: tokenInfo.id,
+          })
+          .returning();
+        return inserted[0];
+      }
+    }
+  }
+
   async upsertIndicateurValeurs(
     indicateurValeurs: IndicateurValeurInsert[],
     tokenInfo: AuthenticatedUser | undefined

From 0a62215cd146619bdd2550e0e21a9b32e35a8aba Mon Sep 17 00:00:00 2001
From: Marc Rutkowski <marc@attractive-media.fr>
Date: Thu, 16 Jan 2025 09:22:09 +0100
Subject: [PATCH 3/6] Ajoute une fonction de suppression d'une valeur
 d'indicateur

---
 .../delete-valeur-indicateur.request.ts       | 12 ++++++
 .../valeurs/crud-valeurs.router.e2e-spec.ts   | 37 +++++++++++++++++++
 .../valeurs/crud-valeurs.router.ts            |  7 +++-
 .../valeurs/crud-valeurs.service.ts           | 26 +++++++++++++
 4 files changed, 81 insertions(+), 1 deletion(-)
 create mode 100644 backend/src/indicateurs/shared/models/delete-valeur-indicateur.request.ts

diff --git a/backend/src/indicateurs/shared/models/delete-valeur-indicateur.request.ts b/backend/src/indicateurs/shared/models/delete-valeur-indicateur.request.ts
new file mode 100644
index 0000000000..d622a25008
--- /dev/null
+++ b/backend/src/indicateurs/shared/models/delete-valeur-indicateur.request.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+/** Suppression d'une valeur d'indicateur pour une collectivité */
+export const deleteValeurIndicateurSchema = z.object({
+  collectiviteId: z.number(),
+  indicateurId: z.number(),
+  id: z.number(),
+});
+
+export type DeleteValeurIndicateur = z.infer<
+  typeof deleteValeurIndicateurSchema
+>;
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts b/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
index 164355bcef..a24b80884c 100644
--- a/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.router.e2e-spec.ts
@@ -176,4 +176,41 @@ describe("Route de lecture/écriture des valeurs d'indicateurs", () => {
     };
     await expect(caller.indicateurs.valeurs.upsert(input)).rejects.toThrow();
   });
+
+  test('Permet de supprimer une valeur', async () => {
+    const caller = router.createCaller({ user: yoloDodoUser });
+
+    // insère une valeur
+    const inputInsert: InputUpsert = {
+      collectiviteId,
+      indicateurId,
+      dateValeur: '2021-01-01',
+      resultat: 42,
+      resultatCommentaire: 'commentaire',
+    };
+    await caller.indicateurs.valeurs.upsert(inputInsert);
+
+    // vérifie le nombre de valeurs avant la suppression
+    const inputBefore: InputList = {
+      collectiviteId,
+      indicateurIds: [indicateurId],
+    };
+    const resultBefore = await caller.indicateurs.valeurs.list(inputBefore);
+    expect(
+      resultBefore.indicateurs[0].sources.collectivite.valeurs.length
+    ).toBe(1);
+
+    // supprime l'entrée
+    const valeurId =
+      resultBefore.indicateurs[0].sources.collectivite.valeurs[0].id;
+    await caller.indicateurs.valeurs.delete({
+      collectiviteId,
+      indicateurId,
+      id: valeurId,
+    });
+
+    // vérifie le nombre de valeurs après la suppression
+    const resultAfter = await caller.indicateurs.valeurs.list(inputBefore);
+    expect(resultAfter.indicateurs.length).toBe(0);
+  });
 });
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.router.ts b/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
index 05603d987c..ce6deecd90 100644
--- a/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.router.ts
@@ -1,6 +1,6 @@
 import { TrpcService } from '@/backend/utils/trpc/trpc.service';
 import { Injectable } from '@nestjs/common';
-import { upsertValeurIndicateurSchema } from '../index-domain';
+import { deleteValeurIndicateurSchema } from '../shared/models/delete-valeur-indicateur.request';
 import { getIndicateursValeursRequestSchema } from '../shared/models/get-indicateurs.request';
 import { upsertValeurIndicateurSchema } from '../shared/models/upsert-valeur-indicateur.request';
 import IndicateurValeursService from './crud-valeurs.service';
@@ -23,5 +23,10 @@ export class IndicateurValeursRouter {
       .mutation(({ input, ctx }) => {
         return this.service.upsertValeur(input, ctx.user);
       }),
+    delete: this.trpc.authedProcedure
+      .input(deleteValeurIndicateurSchema)
+      .mutation(({ input, ctx }) => {
+        return this.service.deleteValeurIndicateur(input, ctx.user);
+      }),
   });
 }
diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.service.ts b/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
index 59fa6ec8ab..8ca66bd504 100644
--- a/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
@@ -22,6 +22,7 @@ import * as _ from 'lodash';
 import { AuthenticatedUser, AuthRole } from '../../auth/models/auth.models';
 import { DatabaseService } from '../../utils/database/database.service';
 import { DeleteIndicateursValeursRequestType } from '../shared/models/delete-indicateurs.request';
+import { DeleteValeurIndicateur } from '../shared/models/delete-valeur-indicateur.request';
 import { GetIndicateursValeursRequestType } from '../shared/models/get-indicateurs.request';
 import { GetIndicateursValeursResponseType } from '../shared/models/get-indicateurs.response';
 import {
@@ -368,6 +369,31 @@ export default class CrudValeursService {
     }
   }
 
+  async deleteValeurIndicateur(
+    data: DeleteValeurIndicateur,
+    tokenInfo: AuthenticatedUser
+  ) {
+    const { collectiviteId, indicateurId, id } = data;
+    await this.permissionService.isAllowed(
+      tokenInfo,
+      PermissionOperation.INDICATEURS_EDITION,
+      ResourceType.COLLECTIVITE,
+      collectiviteId
+    );
+
+    if (tokenInfo.role === AuthRole.AUTHENTICATED && tokenInfo.id) {
+      await this.databaseService.db
+        .delete(indicateurValeurTable)
+        .where(
+          and(
+            eq(indicateurValeurTable.collectiviteId, collectiviteId),
+            eq(indicateurValeurTable.indicateurId, indicateurId),
+            eq(indicateurValeurTable.id, id)
+          )
+        );
+    }
+  }
+
   async upsertIndicateurValeurs(
     indicateurValeurs: IndicateurValeurInsert[],
     tokenInfo: AuthenticatedUser | undefined

From ba051e770b5bba93e79faee87b7d6754c3d150a2 Mon Sep 17 00:00:00 2001
From: Marc Rutkowski <marc@attractive-media.fr>
Date: Tue, 21 Jan 2025 18:56:40 +0100
Subject: [PATCH 4/6] =?UTF-8?q?D=C3=A9rive=20le=20type=20plut=C3=B4t=20que?=
 =?UTF-8?q?=20de=20red=C3=A9finir=20le=20typage=20de=20chaque=20champ=20vo?=
 =?UTF-8?q?ulu?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../upsert-valeur-indicateur.request.ts       | 27 ++++++++++++-------
 1 file changed, 17 insertions(+), 10 deletions(-)

diff --git a/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts b/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
index d905979910..671ed9c824 100644
--- a/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
+++ b/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
@@ -1,16 +1,23 @@
 import { z } from 'zod';
+import { indicateurValeurSchemaInsert } from './indicateur-valeur.table';
 
 /** Upsert d'une valeur d'indicateur pour une collectivité */
-export const upsertValeurIndicateurSchema = z.object({
-  collectiviteId: z.number(),
-  indicateurId: z.number(),
-  id: z.number().optional(), // pour un update
-  dateValeur: z.string().optional(), // pour un insert
-  resultat: z.number().nullish(),
-  resultatCommentaire: z.string().nullish(),
-  objectif: z.number().nullish(),
-  objectifCommentaire: z.string().nullish(),
-});
+const shape = indicateurValeurSchemaInsert.shape;
+const upsertValeurIndicateurSchema = indicateurValeurSchemaInsert
+  .pick({
+    collectiviteId: true,
+    indicateurId: true,
+  })
+  .merge(
+    z.object({
+      id: shape.id.optional(), // pour un update
+      dateValeur: shape.dateValeur.optional(), // pour un insert
+      resultat: shape.resultat.nullish(),
+      resultatCommentaire: shape.resultatCommentaire.nullish(),
+      objectif: shape.objectif.nullish(),
+      objectifCommentaire: shape.objectifCommentaire.nullish(),
+    })
+  );
 
 export type UpsertValeurIndicateur = z.infer<
   typeof upsertValeurIndicateurSchema

From f0ccfecc397efe6298886bd68108c5fa5673b9ae Mon Sep 17 00:00:00 2001
From: Marc Rutkowski <marc@attractive-media.fr>
Date: Wed, 22 Jan 2025 16:41:25 +0100
Subject: [PATCH 5/6] Simplifie le typage

---
 .../upsert-valeur-indicateur.request.ts       | 21 ++++++++-----------
 1 file changed, 9 insertions(+), 12 deletions(-)

diff --git a/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts b/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
index 671ed9c824..f1d57211e1 100644
--- a/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
+++ b/backend/src/indicateurs/shared/models/upsert-valeur-indicateur.request.ts
@@ -2,22 +2,19 @@ import { z } from 'zod';
 import { indicateurValeurSchemaInsert } from './indicateur-valeur.table';
 
 /** Upsert d'une valeur d'indicateur pour une collectivité */
-const shape = indicateurValeurSchemaInsert.shape;
-const upsertValeurIndicateurSchema = indicateurValeurSchemaInsert
+export const upsertValeurIndicateurSchema = indicateurValeurSchemaInsert
   .pick({
     collectiviteId: true,
     indicateurId: true,
+    id: true,
+    resultat: true,
+    resultatCommentaire: true,
+    objectif: true,
+    objectifCommentaire: true,
   })
-  .merge(
-    z.object({
-      id: shape.id.optional(), // pour un update
-      dateValeur: shape.dateValeur.optional(), // pour un insert
-      resultat: shape.resultat.nullish(),
-      resultatCommentaire: shape.resultatCommentaire.nullish(),
-      objectif: shape.objectif.nullish(),
-      objectifCommentaire: shape.objectifCommentaire.nullish(),
-    })
-  );
+  .extend({
+    dateValeur: indicateurValeurSchemaInsert.shape.dateValeur.optional(),
+  });
 
 export type UpsertValeurIndicateur = z.infer<
   typeof upsertValeurIndicateurSchema

From 2ce3874b7694d536ad281e4a39bb2741a1379399 Mon Sep 17 00:00:00 2001
From: Marc Rutkowski <marc@attractive-media.fr>
Date: Wed, 22 Jan 2025 16:41:35 +0100
Subject: [PATCH 6/6] Ajoute un commentaire

---
 backend/src/indicateurs/valeurs/crud-valeurs.service.ts | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/backend/src/indicateurs/valeurs/crud-valeurs.service.ts b/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
index 8ca66bd504..7fba66452b 100644
--- a/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
+++ b/backend/src/indicateurs/valeurs/crud-valeurs.service.ts
@@ -307,6 +307,12 @@ export default class CrudValeursService {
     return definitions;
   }
 
+  /**
+   * Variante de `upsertIndicateurValeurs` qui permet de ne pas être obligé de
+   * redonner l'objet complet sans pour autant écraser la valeur existante. Et
+   * donc de mettre à jour la colonne resultat indépendamment de la valeur
+   * objectif (et pareil pour les commentaires).
+   */
   async upsertValeur(
     data: UpsertValeurIndicateur,
     tokenInfo: AuthenticatedUser