Commit 164bd143 authored by Anthony Gautreau's avatar Anthony Gautreau
Browse files

Import Pegase : détails d'objets maquette lus dans ObjetMaquette (champs...

Import Pegase : détails d'objets maquette lus dans ObjetMaquette (champs typés) au lieu de tableaux réduits par champsLus(), objet sans code refusé
parent ea4b75d7
Loading
Loading
Loading
Loading
+110 −0
Changes for api/src/Pegase/ObjetMaquette.php: 110 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

namespace App\Pegase;

/**
 * Détail d'un objet maquette réduit aux champs lus par l'importeur : les détails complets d'une période ne
 * tiendraient pas en mémoire. Un champ nouvellement lu doit être ajouté ici et dans depuisApi().
 */
final readonly class ObjetMaquette
{
    /**
     * @param string             $code                code de l'objet (LAE112)
     * @param string|null        $libelle             libellé court, null s'il est vide
     * @param string|null        $libelleLong         libellé long, null s'il est vide
     * @param float|null         $ects                ECTS de l'objet
     * @param string|null        $structurePrincipale code de la structure principale
     * @param list<string>       $structuresPorteuses codes des structures porteuses
     * @param string|null        $description         description du syllabus, null si elle est vide
     * @param string|null        $typeDiplome         code du type de diplôme SISE
     * @param string|null        $niveauDiplome       niveau dans le diplôme SISE
     * @param string|null        $formationPorteuse   identifiant de la formation porteuse
     * @param list<float>        $volumesHoraires     volumes des formats d'enseignement, en minutes
     * @param list<list<string>> $chemins             chemins des contextes valides, de la formation à l'objet
     */
    public function __construct(
        public string $code,
        public ?string $libelle,
        public ?string $libelleLong,
        public ?float $ects,
        public ?string $structurePrincipale,
        public array $structuresPorteuses,
        public ?string $description,
        public ?string $typeDiplome,
        public ?string $niveauDiplome,
        public ?string $formationPorteuse,
        public array $volumesHoraires,
        public array $chemins,
    ) {
    }

    /**
     * Construit l'objet à partir d'une réponse de GET objets-maquette/{id}.
     *
     * @param array<mixed> $objet détail complet
     *
     * @return self détail réduit
     *
     * @throws PegaseException si l'objet n'a pas de code
     */
    public static function depuisApi(array $objet): self
    {
        $code = $objet['code'] ?? null;
        if (!\is_string($code) || '' === $code) {
            throw new PegaseException(\sprintf('Pegase, objet maquette %s sans code', $objet['id'] ?? 'inconnu'));
        }
        $descripteurs = $objet['descripteursObjetMaquette'] ?? [];
        $sise = $objet['descripteursEnquete']['descripteursSise'] ?? [];
        $formats = $objet['formatsEnseignement'] ?? [];
        $volumes = array_column($formats['formatsEnseignement'] ?? [], 'volumeHoraire');
        $volumes = array_filter($volumes, 'is_numeric');
        $structuresPorteuses = array_filter((array) ($formats['structuresPorteuse'] ?? []), 'is_string');
        $chemins = [];
        foreach ($objet['contextes'] ?? [] as $contexte) {
            $chemin = $contexte['chemin'] ?? [];
            if ($chemin && false !== ($contexte['valide'] ?? true)) {
                $chemins[] = $chemin;
            }
        }

        return new self(
            code: $code,
            libelle: self::texte($descripteurs['libelle'] ?? null),
            libelleLong: self::texte($descripteurs['libelleLong'] ?? null),
            ects: isset($descripteurs['ects']) ? (float) $descripteurs['ects'] : null,
            structurePrincipale: self::chaine($descripteurs['structurePrincipale'] ?? null),
            structuresPorteuses: array_values($structuresPorteuses),
            description: self::texte($objet['descripteursSyllabus']['description'] ?? null),
            typeDiplome: self::chaine($sise['typeDiplome']['code'] ?? null),
            niveauDiplome: self::chaine($sise['niveauDiplomeSise'] ?? null),
            formationPorteuse: self::chaine($formats['formationPorteuse'] ?? null),
            volumesHoraires: array_values(array_map('floatval', $volumes)),
            chemins: $chemins,
        );
    }

    /**
     * @return string libellé long, à défaut libellé court, à défaut code
     */
    public function libelle(): string
    {
        return $this->libelleLong ?? $this->libelle ?? $this->code;
    }

    private static function chaine(mixed $valeur): ?string
    {
        return \is_string($valeur) ? $valeur : null;
    }

    /**
     * @return string|null texte sans espaces autour, null s'il est vide
     */
    private static function texte(mixed $valeur): ?string
    {
        if (!\is_string($valeur) || '' === trim($valeur)) {
            return null;
        }

        return trim($valeur);
    }
}
+6 −5
Changes for api/src/Pegase/PegaseClient.php: 6 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -164,10 +164,11 @@ final class PegaseClient
     * GET en parallèle sur l'API ODF, au plus $concurrence requêtes à la fois.
     *
     * @param array<string|int, string>            $chemins  clé => chemin relatif à /etablissement/{code}
     * @param (\Closure(array<mixed>): array<mixed>)|null $extraire appliquée à chaque réponse dès son arrivée,
     *                                                              pour ne garder en mémoire que les champs utiles
     * @param (\Closure(array<mixed>): mixed)|null $extraire appliquée à chaque réponse dès son arrivée, pour ne
     *                                                       garder en mémoire que les champs utiles
     *
     * @return array<string|int, array<mixed>> clé => réponse JSON décodée (puis extraite), dans l'ordre d'arrivée
     * @return array<string|int, mixed> clé => réponse JSON décodée, ou ce qu'en rend $extraire, dans l'ordre
     *                                  d'arrivée
     *
     * @throws PegaseException dès qu'une requête échoue définitivement
     */
@@ -178,9 +179,9 @@ final class PegaseClient

    /**
     * @param array<string|int, string>            $urls     clé => URL complète
     * @param (\Closure(array<mixed>): array<mixed>)|null $extraire appliquée à chaque réponse décodée
     * @param (\Closure(array<mixed>): mixed)|null $extraire appliquée à chaque réponse décodée
     *
     * @return array<string|int, array<mixed>> clé => réponse décodée
     * @return array<string|int, mixed> clé => réponse décodée, ou ce qu'en rend $extraire
     */
    private function executer(array $urls, ?\Closure $extraire = null): array
    {
+41 −123
Changes for api/src/Pegase/PegaseImporter.php: 41 added lines, 123 removed lines.
Original line number Diff line number Diff line
@@ -184,21 +184,17 @@ final class PegaseImporter
    /**
     * Contextes valides d'un cours, ceux des formations catalogue seulement à défaut d'autres.
     *
     * @param array<mixed> $detail           détail du cours
     * @param ObjetMaquette $detail           détail du cours
     * @param list<string>  $racinesCatalogue identifiants des formations catalogue
     *
     * @return list<array{0: string, 1: string|null}> [formation, année], sans année pour un cours placé
     *                                                directement sous la formation
     */
    private function contextes(array $detail, array $racinesCatalogue): array
    private function contextes(ObjetMaquette $detail, array $racinesCatalogue): array
    {
        $horsCatalogue = [];
        $catalogue = [];
        foreach ($detail['contextes'] ?? [] as $contexte) {
            $chemin = $contexte['chemin'] ?? [];
            if (!$chemin || false === ($contexte['valide'] ?? true)) {
                continue;
            }
        foreach ($detail->chemins as $chemin) {
            // Le chemin se termine par le cours : l'année n'existe qu'avec au moins trois éléments
            $paire = [$chemin[0], \count($chemin) >= 3 ? $chemin[1] : null];
            if (\in_array($chemin[0], $racinesCatalogue, true)) {
@@ -212,7 +208,7 @@ final class PegaseImporter
    }

    /**
     * @param array<string, array<mixed>>                           $details   détails des cours
     * @param array<string, ObjetMaquette>                          $details   détails des cours
     * @param array<string, list<array{0: string, 1: string|null}>> $contextes contextes retenus de chaque cours
     *
     * @return list<string> identifiants des formations, années et formations porteuses à lire
@@ -227,9 +223,8 @@ final class PegaseImporter
                    $lies[$annee] = true;
                }
            }
            $porteuse = $detail['formatsEnseignement']['formationPorteuse'] ?? null;
            if ($porteuse) {
                $lies[$porteuse] = true;
            if ($detail->formationPorteuse) {
                $lies[$detail->formationPorteuse] = true;
            }
        }

@@ -239,85 +234,40 @@ final class PegaseImporter
    /**
     * @param list<string> $ids identifiants d'objets maquette
     *
     * @return array<string, array<mixed>> identifiant => détail réduit aux champs lus
     * @return array<string, ObjetMaquette> identifiant => détail réduit aux champs lus
     */
    private function lireObjets(array $ids): array
    {
        $chemins = array_map(static fn (string $id): string => 'objets-maquette/'.$id, $ids);

        return $this->client->odfParallele(array_combine($ids, $chemins), self::champsLus(...));
    }

    /**
     * Réduit le détail d'un objet maquette aux champs lus par l'importeur : les détails d'une période ne
     * tiendraient pas en mémoire en entier. Un champ nouvellement lu doit être ajouté ici.
     *
     * @param array<mixed> $objet détail complet
     *
     * @return array<mixed> détail réduit, de même structure
     */
    private static function champsLus(array $objet): array
    {
        $descripteurs = $objet['descripteursObjetMaquette'] ?? [];
        $descripteursLus = ['libelle', 'libelleLong', 'ects', 'structurePrincipale'];
        $sise = $objet['descripteursEnquete']['descripteursSise'] ?? [];
        $formats = $objet['formatsEnseignement'] ?? [];
        $volumes = array_map(
            static fn (array $format): array => ['volumeHoraire' => $format['volumeHoraire'] ?? null],
            $formats['formatsEnseignement'] ?? [],
        );
        $contextes = array_map(
            static fn (array $contexte): array => [
                'chemin' => $contexte['chemin'] ?? [],
                'valide' => $contexte['valide'] ?? null,
            ],
            $objet['contextes'] ?? [],
        );

        return [
            'code' => $objet['code'] ?? null,
            'descripteursObjetMaquette' => array_intersect_key($descripteurs, array_flip($descripteursLus)),
            'descripteursSyllabus' => ['description' => $objet['descripteursSyllabus']['description'] ?? null],
            'descripteursEnquete' => [
                'descripteursSise' => [
                    'typeDiplome' => ['code' => $sise['typeDiplome']['code'] ?? null],
                    'niveauDiplomeSise' => $sise['niveauDiplomeSise'] ?? null,
                ],
            ],
            'formatsEnseignement' => [
                'formationPorteuse' => $formats['formationPorteuse'] ?? null,
                'structuresPorteuse' => $formats['structuresPorteuse'] ?? null,
                'formatsEnseignement' => $volumes,
            ],
            'contextes' => $contextes,
        ];
        return $this->client->odfParallele(array_combine($ids, $chemins), ObjetMaquette::depuisApi(...));
    }

    /**
     * @param non-empty-list<CoursCatalogue>         $occurrences occurrences du cours dans la période
     * @param array<mixed>                           $detail      détail de l'objet
     * @param ObjetMaquette                          $detail      détail du cours
     * @param list<array{0: string, 1: string|null}> $contextes   contextes retenus du cours
     * @param array<string, array<mixed>>            $objets      détails des formations et années liées
     * @param array<string, ObjetMaquette>           $objets      détails des formations et années liées
     */
    private function course(array $occurrences, array $detail, array $contextes, array $objets): Course
    private function course(array $occurrences, ObjetMaquette $detail, array $contextes, array $objets): Course
    {
        $premier = $occurrences[0];
        $course = $this->courses[$premier->code] ??= new Course();
        $course->code = $premier->code;
        $libelleLong = $this->texte($detail['descripteursObjetMaquette']['libelleLong'] ?? null);
        $course->title = $this->titre($libelleLong ?? $premier->libelle);
        $course->title = $this->titre($detail->libelleLong ?? $premier->libelle);
        $course->ects = $premier->ects;
        $course->languageOfInstruction = [$this->configuration->langue($premier->type)];
        $course->academicTerm = array_map(
            static fn (int $semestre): string => $semestre.'/2',
            $this->semestres($occurrences),
        );
        $description = $this->texte($detail['descripteursSyllabus']['description'] ?? null);
        $course->description = null === $description ? null : [['multiline' => $description, 'lang' => 'fr']];
        $idPorteuse = $detail['formatsEnseignement']['formationPorteuse'] ?? '';
        $porteuse = $objets[$idPorteuse] ?? null;
        $course->diplomaCode = $porteuse['code'] ?? null;
        $course->diplomaTitle = $porteuse ? $this->titre($this->libelle($porteuse)) : null;
        $course->description = null;
        if (null !== $detail->description) {
            $course->description = [['multiline' => $detail->description, 'lang' => 'fr']];
        }
        $porteuse = $objets[$detail->formationPorteuse ?? ''] ?? null;
        $course->diplomaCode = $porteuse?->code;
        $course->diplomaTitle = null === $porteuse ? null : $this->titre($porteuse->libelle());
        $course->ounit = $this->ounit($this->structureCours($detail, $contextes, $objets));
        $course->relatedProgramme = new ArrayCollection($this->programmesLies($contextes, $objets));
        $course->lastModified = $this->date;
@@ -341,7 +291,7 @@ final class PegaseImporter

    /**
     * @param list<array{0: string, 1: string|null}> $contextes contextes retenus du cours
     * @param array<string, array<mixed>>            $objets    détails des formations et années liées
     * @param array<string, ObjetMaquette>           $objets    détails des formations et années liées
     *
     * @return list<Programme> programmes des années où le cours est placé, sans doublon
     */
@@ -362,26 +312,22 @@ final class PegaseImporter
     * Structure porteuse, la première connue du référentiel parmi : structures porteuses, structure
     * principale, structure de l'année de la formation porteuse, puis des autres années liées.
     *
     * @param array<mixed>                           $detail
     * @param list<array{0: string, 1: string|null}> $contextes
     * @param array<string, array<mixed>>            $objets
     * @param array<string, ObjetMaquette>           $objets
     */
    private function structureCours(array $detail, array $contextes, array $objets): ?string
    private function structureCours(ObjetMaquette $detail, array $contextes, array $objets): ?string
    {
        $candidates = [
            ...(array) ($detail['formatsEnseignement']['structuresPorteuse'] ?? []),
            $detail['descripteursObjetMaquette']['structurePrincipale'] ?? null,
        ];
        $porteuse = $detail['formatsEnseignement']['formationPorteuse'] ?? null;
        $candidates = [...$detail->structuresPorteuses, $detail->structurePrincipale];
        $porteuse = $detail->formationPorteuse;
        $deLaPorteuse = array_filter($contextes, static fn (array $contexte): bool => $porteuse === $contexte[0]);
        $autres = array_filter($contextes, static fn (array $contexte): bool => $porteuse !== $contexte[0]);
        foreach ([...$deLaPorteuse, ...$autres] as [, $annee]) {
            if (null !== $annee) {
                $candidates[] = $objets[$annee]['descripteursObjetMaquette']['structurePrincipale'] ?? null;
                $candidates[] = $objets[$annee]->structurePrincipale;
            }
        }
        foreach ($candidates as $structure) {
            if (\is_string($structure) && isset($this->structures[$structure])) {
            if (null !== $structure && isset($this->structures[$structure])) {
                return $structure;
            }
        }
@@ -390,24 +336,22 @@ final class PegaseImporter
    }

    /**
     * @param array<mixed> $annee     détail de l'année
     * @param array<mixed> $formation détail de la formation qui la contient
     * @param ObjetMaquette $annee     détail de l'année
     * @param ObjetMaquette $formation détail de la formation qui la contient
     */
    private function programme(array $annee, array $formation): Programme
    private function programme(ObjetMaquette $annee, ObjetMaquette $formation): Programme
    {
        $descripteurs = $annee['descripteursObjetMaquette'] ?? [];
        $typeDiplome = $formation['descripteursEnquete']['descripteursSise']['typeDiplome']['code'] ?? null;

        $programme = $this->programmes[$annee['code']] ??= new Programme();
        $programme->code = $annee['code'];
        $programme->title = $this->titre($this->libelle($annee));
        $programme->ects = isset($descripteurs['ects']) ? (float) $descripteurs['ects'] : null;
        $programme->eqfLevelProvided = self::NIVEAUX_EQF[$this->cursus[$typeDiplome] ?? ''] ?? null;
        $programme = $this->programmes[$annee->code] ??= new Programme();
        $programme->code = $annee->code;
        $programme->title = $this->titre($annee->libelle());
        $programme->ects = $annee->ects;
        $cursus = $this->cursus[$formation->typeDiplome ?? ''] ?? '';
        $programme->eqfLevelProvided = self::NIVEAUX_EQF[$cursus] ?? null;
        $programme->languageOfInstruction = ['fr'];
        $programme->levelInDiploma = $annee['descripteursEnquete']['descripteursSise']['niveauDiplomeSise'] ?? null;
        $programme->diplomaCode = $formation['code'];
        $programme->diplomaTitle = $this->titre($this->libelle($formation));
        $programme->ounit = $this->ounit($descripteurs['structurePrincipale'] ?? null);
        $programme->levelInDiploma = $annee->niveauDiplome;
        $programme->diplomaCode = $formation->code;
        $programme->diplomaTitle = $this->titre($formation->libelle());
        $programme->ounit = $this->ounit($annee->structurePrincipale);
        $programme->lastModified = $this->date;

        return $programme;
@@ -416,10 +360,8 @@ final class PegaseImporter
    /**
     * Une instance par cours, période et semestre. Un cours placé deux fois sous le même semestre garde son
     * premier regroupement.
     *
     * @param array<mixed> $detail
     */
    private function instance(Course $course, CoursCatalogue $cours, array $detail, Periode $periode): void
    private function instance(Course $course, CoursCatalogue $cours, ObjetMaquette $detail, Periode $periode): void
    {
        $id = $course->code.'-'.$periode->annee;
        $academicTermId = $periode->annee.'/'.($periode->annee + 1);
@@ -430,8 +372,7 @@ final class PegaseImporter
        if (isset($this->instances[$id])) {
            return;
        }
        $volumes = array_column($detail['formatsEnseignement']['formatsEnseignement'] ?? [], 'volumeHoraire');
        $volumes = array_filter($volumes, 'is_numeric');
        $volumes = $detail->volumesHoraires;

        $instance = new CourseInstance();
        $instance->id = $id;
@@ -440,7 +381,6 @@ final class PegaseImporter
        [$instance->startDate, $instance->endDate] = $this->configuration->dates($periode->annee, $cours->semestre);
        $instance->ects = $cours->ects;
        $instance->languageOfInstruction = [$this->configuration->langue($cours->type)];
        // volumeHoraire en minutes
        $instance->engagementHours = $volumes ? round(array_sum($volumes) / 60, 2) : null;
        $instance->catalogueOunit = null === $cours->composante ? null : $this->titre($cours->composante);
        $instance->catalogueGroup = null === $cours->regroupement ? null : $this->titre($cours->regroupement);
@@ -497,28 +437,6 @@ final class PegaseImporter
        );
    }

    /**
     * @param array<mixed> $objet détail d'un objet maquette
     */
    private function libelle(array $objet): string
    {
        return $this->texte($objet['descripteursObjetMaquette']['libelleLong'] ?? null)
            ?? $this->texte($objet['descripteursObjetMaquette']['libelle'] ?? null)
            ?? $objet['code'];
    }

    /**
     * @return string|null texte sans espaces autour, null s'il est vide
     */
    private function texte(mixed $valeur): ?string
    {
        if (!\is_string($valeur) || '' === trim($valeur)) {
            return null;
        }

        return trim($valeur);
    }

    /**
     * @return list<array{string: string, lang: string}>
     */
+94 −0
Changes for api/tests/Pegase/ObjetMaquetteTest.php: 94 added lines, 0 removed lines.
Original line number Diff line number Diff line
<?php

namespace App\Tests\Pegase;

use App\Pegase\ObjetMaquette;
use App\Pegase\PegaseException;
use PHPUnit\Framework\TestCase;

/**
 * Vérifie la réduction d'un détail d'objet maquette aux champs lus.
 */
class ObjetMaquetteTest extends TestCase
{
    /**
     * Vérifie la lecture de chaque champ d'un détail complet.
     */
    public function testDetailComplet(): void
    {
        $objet = ObjetMaquette::depuisApi([
            'code' => 'LAE112',
            'descripteursObjetMaquette' => [
                'libelle' => ' Anglais ',
                'libelleLong' => ' Anglais renforcé ',
                'ects' => '6',
                'structurePrincipale' => 'CMP1',
            ],
            'descripteursSyllabus' => ['description' => ' Description '],
            'descripteursEnquete' => ['descripteursSise' => ['typeDiplome' => ['code' => 'XB'], 'niveauDiplomeSise' => '01']],
            'formatsEnseignement' => [
                'formationPorteuse' => 'f1',
                'structuresPorteuse' => ['CMP2', 'CMP3'],
                'formatsEnseignement' => [['volumeHoraire' => 600], ['volumeHoraire' => '90'], ['volumeHoraire' => null]],
            ],
            'contextes' => [
                ['chemin' => ['f1', 'a1', 'c1'], 'valide' => true],
                ['chemin' => ['f2', 'c1']],
                ['chemin' => ['f3', 'a3', 'c1'], 'valide' => false],
                ['chemin' => []],
            ],
        ]);

        $this->assertSame('LAE112', $objet->code);
        $this->assertSame('Anglais', $objet->libelle);
        $this->assertSame('Anglais renforcé', $objet->libelleLong);
        $this->assertSame('Anglais renforcé', $objet->libelle());
        $this->assertSame(6.0, $objet->ects);
        $this->assertSame('CMP1', $objet->structurePrincipale);
        $this->assertSame(['CMP2', 'CMP3'], $objet->structuresPorteuses);
        $this->assertSame('Description', $objet->description);
        $this->assertSame('XB', $objet->typeDiplome);
        $this->assertSame('01', $objet->niveauDiplome);
        $this->assertSame('f1', $objet->formationPorteuse);
        $this->assertSame([600.0, 90.0], $objet->volumesHoraires);
        $this->assertSame([['f1', 'a1', 'c1'], ['f2', 'c1']], $objet->chemins);
    }

    /**
     * Vérifie les valeurs par défaut d'un détail aux champs absents, vides ou non textuels, et le repli du libellé
     * sur le code.
     */
    public function testDetailMinimal(): void
    {
        $objet = ObjetMaquette::depuisApi([
            'code' => 'X1',
            'descripteursObjetMaquette' => ['libelle' => ' ', 'libelleLong' => null, 'structurePrincipale' => 901],
            'descripteursEnquete' => ['descripteursSise' => ['typeDiplome' => ['code' => ['XB']]]],
            'formatsEnseignement' => ['structuresPorteuse' => ['CMP1', 901], 'formationPorteuse' => null],
        ]);

        $this->assertNull($objet->libelle);
        $this->assertNull($objet->libelleLong);
        $this->assertSame('X1', $objet->libelle());
        $this->assertNull($objet->ects);
        $this->assertNull($objet->structurePrincipale);
        $this->assertSame(['CMP1'], $objet->structuresPorteuses);
        $this->assertNull($objet->description);
        $this->assertNull($objet->typeDiplome);
        $this->assertNull($objet->niveauDiplome);
        $this->assertNull($objet->formationPorteuse);
        $this->assertSame([], $objet->volumesHoraires);
        $this->assertSame([], $objet->chemins);
    }

    /**
     * Vérifie le refus d'un objet sans code.
     */
    public function testSansCode(): void
    {
        $this->expectException(PegaseException::class);
        $this->expectExceptionMessage('objet maquette o1 sans code');

        ObjetMaquette::depuisApi(['id' => 'o1', 'code' => '']);
    }
}