Repository: betagouv/service-national-universel
Pull request #4742: fix(apiv2): 242 - Correction mise à jour des classes
Author: login: achorein
Labels: name: deploy
description: Deploy custom env
Created: 2025-02-14T14:21:22Z | Merged: 2025-03-18T11:31:56Z

**Description**

<!-- Explain the **motivation** for making this change. What existing problem does the pull request solve? screenshot? -->

**Todo**

- [x] Empêcher l'import des fichiers avec une classe à la fois dans import et dans désister
- [x] Ajout des transactions pour s'assurer que la modification d'une classe est cohérente entre le status de la classe et les jeunes + patches

**Ticket / Issue**

https://www.notion.so/jeveuxaider/Correction-mise-jour-des-classes-19772a322d5080fd8956c61eb8882764?pvs=23

**Testing instructions**

<!--
    Explain how another dev can test this PR. Create a workflow using checkboxes to explain how to run your code and the expected outputs:

    ${{ Test the following }}
    - [x] ${{ QA Scenario 1 }}
    - [x] ${{ QA Scenario 2 }}
    - [x] ${{ QA Scenario 3 }}
-->



Diff:
diff --git a/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.spec.ts b/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.spec.ts
index bba78fc65a..9816f88bfc 100644
--- a/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.spec.ts
+++ b/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.spec.ts
@@ -1,6 +1,10 @@
 import { ReferentielTaskType, STATUS_CLASSE, YOUNG_STATUS } from "snu-lib";
 import { DesisterClasses } from "./DesisterClasses";
 
+jest.mock("@nestjs-cls/transactional", () => ({
+    Transactional: () => jest.fn(),
+}));
+
 describe("DesisterClasses", () => {
     let useCase: DesisterClasses;
     let mockFileGateway: any;
@@ -44,7 +48,8 @@ describe("DesisterClasses", () => {
         ];
 
         mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
-        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // desist
+        mockFileGateway.parseXLS.mockResolvedValueOnce([]); // import
         mockClasseGateway.findById.mockResolvedValue(mockClasse);
         mockJeuneGateway.findByClasseId.mockResolvedValue(mockJeunes);
         mockClasseGateway.updateStatut.mockResolvedValue({ ...mockClasse, statut: STATUS_CLASSE.WITHDRAWN });
@@ -133,7 +138,8 @@ describe("DesisterClasses", () => {
         ];
 
         mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
-        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // desist
+        mockFileGateway.parseXLS.mockResolvedValueOnce([]); // import
         mockClasseGateway.updateStatut.mockImplementation((id) => ({
             id,
             statut: STATUS_CLASSE.WITHDRAWN,
@@ -161,7 +167,8 @@ describe("DesisterClasses", () => {
         const mockXlsxData = [{ "Identifiant de la classe engagée": "CLASS-001" }];
 
         mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
-        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce([]);
         mockClasseGateway.updateStatut.mockRejectedValue(new Error("Database error"));
 
         const result = await useCase.execute({
@@ -185,7 +192,8 @@ describe("DesisterClasses", () => {
         const mockXlsxData = [{ "Identifiant de la classe engagée": "CLASS-001" }];
 
         mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
-        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // desist
+        mockFileGateway.parseXLS.mockResolvedValueOnce([]); // import
         mockClasseGateway.updateStatut.mockResolvedValue({
             id: "CLASS-001",
             statut: STATUS_CLASSE.WITHDRAWN,
@@ -209,4 +217,32 @@ describe("DesisterClasses", () => {
             jeunesDesistesIds: "",
         });
     });
+
+    it("should handle duplicate import and desist classe error", async () => {
+        const mockXlsxData = [
+            {
+                "Identifiant de la classe engagée": "CLASS-001",
+            },
+        ];
+
+        mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
+        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockClasseGateway.findById.mockResolvedValue(mockXlsxData);
+
+        const result = await useCase.execute({
+            fileKey: "test.xlsx",
+            folderPath: "/test",
+            auteur: { email: "test@test.com", nom: "Test", prenom: "User" },
+            fileName: "test.xlsx",
+            fileLineCount: 1,
+            type: ReferentielTaskType.IMPORT_DESISTER_CLASSES,
+        });
+
+        expect(result[0]).toEqual({
+            classeId: "class-001",
+            result: "error",
+            error: "Classe existe dans les 2 onglets",
+            jeunesDesistesIds: "",
+        });
+    });
 });
diff --git a/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.ts b/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.ts
index 34f18242e0..b34a244667 100644
--- a/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.ts
+++ b/apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.ts
@@ -11,9 +11,12 @@ import {
     ClasseDesisterModel,
     ClasseDesisterRapport,
     ClasseDesisterXlsx,
-    ClasseRapport,
+    ClasseImportModel,
+    ClasseImportXlsx,
     DesisterClasseFileValidation,
+    ImportClasseFileValidation,
 } from "../ReferentielClasse.model";
+import { Transactional } from "@nestjs-cls/transactional";
 Injectable();
 export class DesisterClasses implements UseCase<ClasseDesisterRapport[]> {
     constructor(
@@ -29,9 +32,14 @@ export class DesisterClasses implements UseCase<ClasseDesisterRapport[]> {
         });
         const mappedClasses = ReferentielClasseMapper.mapDesisterClassesFromFile(classesFromXslx);
 
+        const classesImportFromXslx = await this.fileGateway.parseXLS<ClasseImportXlsx>(fileContent.Body, {
+            sheetName: ImportClasseFileValidation.sheetName,
+        });
+        const mappedClassesImport = ReferentielClasseMapper.mapImporterClassesFromFile(classesImportFromXslx);
+
         for (const mappedClasse of mappedClasses) {
             try {
-                const result = await this.processClasse(mappedClasse);
+                const result = await this.processClasse(mappedClasse, mappedClassesImport);
                 report.push(result);
             } catch (error) {
                 report.push({
@@ -45,7 +53,11 @@ export class DesisterClasses implements UseCase<ClasseDesisterRapport[]> {
 
         return report;
     }
-    private async processClasse(classeToDesister: ClasseDesisterModel): Promise<ClasseDesisterRapport> {
+    @Transactional()
+    private async processClasse(
+        classeToDesister: ClasseDesisterModel,
+        classesImport: ClasseImportModel[],
+    ): Promise<ClasseDesisterRapport> {
         if (!classeToDesister.classeId) {
             return {
                 classeId: classeToDesister.classeId,
@@ -54,6 +66,16 @@ export class DesisterClasses implements UseCase<ClasseDesisterRapport[]> {
                 jeunesDesistesIds: "",
             };
         }
+        // Vérifier si la classe existe dans l'onglet d'import
+        if (classesImport.find((classe) => classe.classeId === classeToDesister.classeId)) {
+            return {
+                classeId: classeToDesister.classeId,
+                error: "Classe existe dans les 2 onglets",
+                result: "error",
+                jeunesDesistesIds: "",
+            };
+        }
+
         // Désister classe
         const classeDesistee = await this.classeGateway.updateStatut(
             classeToDesister.classeId,
diff --git a/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.spec.ts b/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.spec.ts
index 2528d8b494..ed4a8c4ba1 100644
--- a/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.spec.ts
+++ b/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.spec.ts
@@ -1,7 +1,9 @@
 import { ImporterClasses } from "./ImporterClasses";
-import { Logger } from "@nestjs/common";
-import { warn } from "console";
-import { MIME_TYPES, ReferentielTaskType, STATUS_CLASSE, STATUS_PHASE1_CLASSE } from "snu-lib";
+import { ReferentielTaskType, STATUS_CLASSE, STATUS_PHASE1_CLASSE } from "snu-lib";
+
+jest.mock("@nestjs-cls/transactional", () => ({
+    Transactional: () => jest.fn(),
+}));
 
 describe("ImporterClasses", () => {
     let useCase: ImporterClasses;
@@ -75,7 +77,8 @@ describe("ImporterClasses", () => {
         ];
 
         mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
-        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // import
+        mockFileGateway.parseXLS.mockResolvedValueOnce([]); // desist
         mockFileGateway.generateExcel.mockResolvedValue(Buffer.from(""));
         mockFileGateway.uploadFile.mockResolvedValue({ Key: "report.xlsx" });
 
@@ -130,7 +133,8 @@ describe("ImporterClasses", () => {
         ];
 
         mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
-        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // import
+        mockFileGateway.parseXLS.mockResolvedValueOnce([]); // desist
         mockFileGateway.generateExcel.mockResolvedValue(Buffer.from(""));
         mockFileGateway.uploadFile.mockResolvedValue({ Key: "report.xlsx" });
 
@@ -160,7 +164,8 @@ describe("ImporterClasses", () => {
         ];
 
         mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
-        mockFileGateway.parseXLS.mockResolvedValue(mockXlsxData);
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // import
+        mockFileGateway.parseXLS.mockResolvedValueOnce([]); // desist
         mockFileGateway.generateExcel.mockResolvedValue(Buffer.from(""));
         mockFileGateway.uploadFile.mockResolvedValue({ Key: "report.xlsx" });
 
@@ -200,4 +205,41 @@ describe("ImporterClasses", () => {
             }),
         ]);
     });
+
+    it("should handle duplicate import and desist classe error", async () => {
+        const mockXlsxData = [
+            {
+                "Session formule": "2024",
+                "Identifiant de la classe engagée": "class-001",
+            },
+        ];
+
+        mockFileGateway.downloadFile.mockResolvedValue({ Body: Buffer.from("") });
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // import
+        mockFileGateway.parseXLS.mockResolvedValueOnce(mockXlsxData); // desist
+        mockFileGateway.generateExcel.mockResolvedValue(Buffer.from(""));
+        mockFileGateway.uploadFile.mockResolvedValue({ Key: "report.xlsx" });
+
+        mockClasseGateway.findById.mockResolvedValue({
+            id: "class-001",
+        });
+
+        const result = await useCase.execute({
+            fileKey: "test.xlsx",
+            folderPath: "/test",
+            auteur: { email: "test@test.com", nom: "Test", prenom: "User" },
+            fileName: "",
+            fileLineCount: 0,
+            type: ReferentielTaskType.IMPORT_CLASSES,
+        });
+
+        expect(result).toEqual([
+            {
+                classeId: "class-001",
+                cohortCode: "2024",
+                error: "Classe existe dans les 2 onglets",
+                sessionCode: "undefined_undefined",
+            },
+        ]);
+    });
 });
diff --git a/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.ts b/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.ts
index b8c99ee643..ef1a946267 100644
--- a/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.ts
+++ b/apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.ts
@@ -15,12 +15,16 @@ import { AnnulerClasseDesistee } from "../../../sejours/cle/classe/useCase/Annul
 import { ReferentielImportTaskParameters } from "../../routes/ReferentielImportTask.model";
 import { ReferentielClasseMapper } from "../ReferentielClasse.mapper";
 import {
+    ClasseDesisterModel,
+    ClasseDesisterXlsx,
     ClasseImportModel,
     ClasseImportRapport,
     ClasseImportXlsx,
     ClasseRapport,
+    DesisterClasseFileValidation,
     ImportClasseFileValidation,
 } from "../ReferentielClasse.model";
+import { Transactional } from "@nestjs-cls/transactional";
 
 @Injectable()
 export class ImporterClasses implements UseCase<ClasseRapport[]> {
@@ -44,9 +48,14 @@ export class ImporterClasses implements UseCase<ClasseRapport[]> {
         });
         const mappedClasses = ReferentielClasseMapper.mapImporterClassesFromFile(classesFromXslx);
 
+        const classesDesisteesFromXslx = await this.fileGateway.parseXLS<ClasseDesisterXlsx>(fileContent.Body, {
+            sheetName: DesisterClasseFileValidation.sheetName,
+        });
+        const mappedClassesDesistees = ReferentielClasseMapper.mapDesisterClassesFromFile(classesDesisteesFromXslx);
+
         for (const mappedClasse of mappedClasses) {
             try {
-                const result = await this.processClasse(mappedClasse);
+                const result = await this.processClasse(mappedClasse, mappedClassesDesistees);
                 report.push(result);
                 this.logger.log(`Classe ${mappedClasse.classeId} imported successfully`, ImporterClasses.name);
             } catch (error) {
@@ -66,7 +75,11 @@ export class ImporterClasses implements UseCase<ClasseRapport[]> {
         return report;
     }
 
-    private async processClasse(classeImport: ClasseImportModel): Promise<ClasseImportRapport> {
+    @Transactional()
+    private async processClasse(
+        classeImport: ClasseImportModel,
+        classesDesistees: ClasseDesisterModel[],
+    ): Promise<ClasseImportRapport> {
         let classe = await this.classeGateway.findById(classeImport.classeId);
         if (!classe) {
             throw new Error("Classe non trouvée en base");
@@ -76,6 +89,14 @@ export class ImporterClasses implements UseCase<ClasseRapport[]> {
             sessionCode: classeImport.sessionCode,
             cohortCode: classeImport.cohortCode,
         };
+        // Vérification de si la classe existe dans l'onglet desistement
+        if (classesDesistees.find((classeDesistee) => classeDesistee.classeId === classe.id)) {
+            return {
+                ...classeRapport,
+                error: "Classe existe dans les 2 onglets",
+            };
+        }
+
         const updatedFields: string[] = [];
 
         // Si la classe est désistée => annuler le désistement de la classe


Reviews:
user: login: Johannbr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T13:04:48Z
user: login: Johannbr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-11T13:06:49Z
user: login: achorein
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T13:14:07Z
user: login: Johannbr
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-11T13:21:28Z

Review comments:
diff_hunk: @@ -29,9 +32,14 @@ export class DesisterClasses implements UseCase<ClasseDesisterRapport[]> {
         });
         const mappedClasses = ReferentielClasseMapper.mapDesisterClassesFromFile(classesFromXslx);
 
+        const classesImportFromXslx = await this.fileGateway.parseXLS<ClasseImportXlsx>(fileContent.Body, {
path: apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.ts
user: login: Johannbr
body: Il est possible que ClasseImportXlsx soit null
created_at: 2025-03-11T13:04:28Z
author_association: COLLABORATOR
diff_hunk: @@ -44,9 +48,14 @@ export class ImporterClasses implements UseCase<ClasseRapport[]> {
         });
         const mappedClasses = ReferentielClasseMapper.mapImporterClassesFromFile(classesFromXslx);
 
+        const classesDesisteesFromXslx = await this.fileGateway.parseXLS<ClasseDesisterXlsx>(fileContent.Body, {
path: apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.ts
user: login: Johannbr
body: Il est possible que ClasseDesisterXlsx soit null
created_at: 2025-03-11T13:04:43Z
author_association: COLLABORATOR
diff_hunk: @@ -29,9 +32,14 @@ export class DesisterClasses implements UseCase<ClasseDesisterRapport[]> {
         });
         const mappedClasses = ReferentielClasseMapper.mapDesisterClassesFromFile(classesFromXslx);
 
+        const classesImportFromXslx = await this.fileGateway.parseXLS<ClasseImportXlsx>(fileContent.Body, {
path: apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.ts
user: login: Johannbr
body: Prévoir un test unitaire pour couvrir ce cas
created_at: 2025-03-11T13:06:25Z
author_association: COLLABORATOR
diff_hunk: @@ -44,9 +48,14 @@ export class ImporterClasses implements UseCase<ClasseRapport[]> {
         });
         const mappedClasses = ReferentielClasseMapper.mapImporterClassesFromFile(classesFromXslx);
 
+        const classesDesisteesFromXslx = await this.fileGateway.parseXLS<ClasseDesisterXlsx>(fileContent.Body, {
path: apiv2/src/admin/core/referentiel/classe/useCase/ImporterClasses.ts
user: login: Johannbr
body: Il est possible que ClasseDesisterXlsx soit null
Prévoir un TU
created_at: 2025-03-11T13:06:47Z
author_association: COLLABORATOR
diff_hunk: @@ -29,9 +32,14 @@ export class DesisterClasses implements UseCase<ClasseDesisterRapport[]> {
         });
         const mappedClasses = ReferentielClasseMapper.mapDesisterClassesFromFile(classesFromXslx);
 
+        const classesImportFromXslx = await this.fileGateway.parseXLS<ClasseImportXlsx>(fileContent.Body, {
path: apiv2/src/admin/core/referentiel/classe/useCase/DesisterClasses.ts
user: login: achorein
body: pour moi non, si l'onglet n'existe pas il renvoi un tableau vide.
created_at: 2025-03-11T13:14:07Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-14T08:19:28Z
body: Application endpoints :

- https://snufiximportclassetr9lgbhtnp-admin.functions.fnc.fr-par.scw.cloud
- https://snufiximportclassetr9lgbhtnp-app.functions.fnc.fr-par.scw.cloud
- https://snufiximportclassetr9lgbhtnp-api.functions.fnc.fr-par.scw.cloud
- https://snufiximportclassetr9lgbhtnp-apiv2.functions.fnc.fr-par.scw.cloud
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-18T09:36:21Z
body: Application endpoints :

- https://snufiximportclassetrkp0gozpn-admin.functions.fnc.fr-par.scw.cloud
- https://snufiximportclassetrkp0gozpn-app.functions.fnc.fr-par.scw.cloud
- https://snufiximportclassetrkp0gozpn-api.functions.fnc.fr-par.scw.cloud
- https://snufiximportclassetrkp0gozpn-apiv2.functions.fnc.fr-par.scw.cloud
author_association: NONE