feat: Permettre l'import d'enseignants via fichier CSV ou XLSX
L'établissement a besoin d'importer en masse ses enseignants depuis les exports des logiciels de vie scolaire (Pronote, EDT, etc.), comme c'est déjà possible pour les élèves. Le wizard en 4 étapes (upload → mapping → aperçu → import) réutilise l'architecture de l'import élèves tout en ajoutant la gestion des matières et des classes enseignées. Corrections de la review #2 intégrées : - La commande ImportTeachersCommand est routée en async via Messenger pour ne pas bloquer la requête HTTP sur les gros fichiers. - Le handler est protégé par un try/catch Throwable pour marquer le batch en échec si une erreur inattendue survient, évitant qu'il reste bloqué en statut "processing". - Les domain events (UtilisateurInvite) sont dispatchés sur l'event bus après chaque création d'utilisateur, déclenchant l'envoi des emails d'invitation. - L'option "mettre à jour les enseignants existants" (AC5) permet de choisir entre ignorer ou mettre à jour nom/prénom et ajouter les affectations manquantes pour les doublons détectés par email.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportTeachers;
|
||||
|
||||
/**
|
||||
* Commande pour lancer l'import d'enseignants en batch.
|
||||
*
|
||||
* Dispatchée de manière asynchrone via le command bus.
|
||||
*/
|
||||
final readonly class ImportTeachersCommand
|
||||
{
|
||||
public function __construct(
|
||||
public string $batchId,
|
||||
public string $tenantId,
|
||||
public string $schoolName,
|
||||
public string $academicYearId,
|
||||
public bool $createMissingSubjects = false,
|
||||
public bool $updateExisting = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Command\ImportTeachers;
|
||||
|
||||
use App\Administration\Application\Service\Import\MultiValueParser;
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassName;
|
||||
use App\Administration\Domain\Model\SchoolClass\SchoolId;
|
||||
use App\Administration\Domain\Model\Subject\Subject;
|
||||
use App\Administration\Domain\Model\Subject\SubjectCode;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectName;
|
||||
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
|
||||
use App\Administration\Domain\Model\User\Email;
|
||||
use App\Administration\Domain\Model\User\Role;
|
||||
use App\Administration\Domain\Model\User\User;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
|
||||
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
|
||||
use App\Administration\Domain\Repository\UserRepository;
|
||||
use App\Administration\Infrastructure\School\SchoolIdResolver;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use DomainException;
|
||||
|
||||
use function in_array;
|
||||
use function mb_strlen;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
use function sprintf;
|
||||
use function strtoupper;
|
||||
use function substr;
|
||||
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Throwable;
|
||||
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Handler pour l'import d'enseignants en batch.
|
||||
*
|
||||
* Traite les lignes valides du batch, crée les enseignants et les affecte
|
||||
* aux matières/classes via TeacherAssignment.
|
||||
*
|
||||
* @see AC4: Import validé → enseignants créés
|
||||
*/
|
||||
#[AsMessageHandler(bus: 'command.bus')]
|
||||
final readonly class ImportTeachersHandler
|
||||
{
|
||||
private MultiValueParser $multiValueParser;
|
||||
|
||||
public function __construct(
|
||||
private TeacherImportBatchRepository $teacherImportBatchRepository,
|
||||
private UserRepository $userRepository,
|
||||
private SubjectRepository $subjectRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private TeacherAssignmentRepository $teacherAssignmentRepository,
|
||||
private SchoolIdResolver $schoolIdResolver,
|
||||
private Connection $connection,
|
||||
private Clock $clock,
|
||||
private LoggerInterface $logger,
|
||||
private MessageBusInterface $eventBus,
|
||||
) {
|
||||
$this->multiValueParser = new MultiValueParser();
|
||||
}
|
||||
|
||||
public function __invoke(ImportTeachersCommand $command): void
|
||||
{
|
||||
$batchId = ImportBatchId::fromString($command->batchId);
|
||||
$tenantId = TenantId::fromString($command->tenantId);
|
||||
$academicYearId = AcademicYearId::fromString($command->academicYearId);
|
||||
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant($command->tenantId));
|
||||
$now = $this->clock->now();
|
||||
|
||||
$batch = $this->teacherImportBatchRepository->get($batchId);
|
||||
|
||||
$batch->demarrer($now);
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
|
||||
$lignes = $batch->lignes();
|
||||
$importedCount = 0;
|
||||
$errorCount = 0;
|
||||
$processedCount = 0;
|
||||
|
||||
try {
|
||||
/** @var array<string, SubjectId> $subjectCache */
|
||||
$subjectCache = [];
|
||||
/** @var list<string> $existingSubjectCodes */
|
||||
$existingSubjectCodes = [];
|
||||
/** @var list<string> $newlyCreatedCodes */
|
||||
$newlyCreatedCodes = [];
|
||||
|
||||
foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) {
|
||||
$subjectCache[(string) $subject->name] = $subject->id;
|
||||
$existingSubjectCodes[] = (string) $subject->code;
|
||||
}
|
||||
|
||||
/** @var array<string, ClassId> */
|
||||
$classCache = [];
|
||||
|
||||
foreach ($lignes as $row) {
|
||||
try {
|
||||
$firstName = trim($row->mappedData[TeacherImportField::FIRST_NAME->value] ?? '');
|
||||
$lastName = trim($row->mappedData[TeacherImportField::LAST_NAME->value] ?? '');
|
||||
$emailRaw = trim($row->mappedData[TeacherImportField::EMAIL->value] ?? '');
|
||||
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? '';
|
||||
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? '';
|
||||
|
||||
$emailVO = new Email($emailRaw);
|
||||
|
||||
$existingUser = $this->userRepository->findByEmail($emailVO, $tenantId);
|
||||
|
||||
if ($existingUser !== null && !$command->updateExisting) {
|
||||
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', $emailRaw));
|
||||
}
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
$subjects = $this->multiValueParser->parse($subjectsRaw);
|
||||
$classes = $this->multiValueParser->parse($classesRaw);
|
||||
|
||||
$resolvedSubjectIds = $this->resolveSubjectIds(
|
||||
$subjects,
|
||||
$tenantId,
|
||||
$schoolId,
|
||||
$command->createMissingSubjects,
|
||||
$now,
|
||||
$subjectCache,
|
||||
$existingSubjectCodes,
|
||||
$newlyCreatedCodes,
|
||||
);
|
||||
|
||||
$resolvedClassIds = $this->resolveClassIds(
|
||||
$classes,
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$classCache,
|
||||
);
|
||||
|
||||
if ($existingUser !== null) {
|
||||
$existingUser->mettreAJourInfos($firstName, $lastName);
|
||||
$this->userRepository->save($existingUser);
|
||||
|
||||
$this->addMissingAssignments(
|
||||
$existingUser,
|
||||
$resolvedSubjectIds,
|
||||
$resolvedClassIds,
|
||||
$tenantId,
|
||||
$academicYearId,
|
||||
$now,
|
||||
);
|
||||
|
||||
$this->connection->commit();
|
||||
} else {
|
||||
$user = User::inviter(
|
||||
email: $emailVO,
|
||||
role: Role::PROF,
|
||||
tenantId: $tenantId,
|
||||
schoolName: $command->schoolName,
|
||||
firstName: $firstName,
|
||||
lastName: $lastName,
|
||||
invitedAt: $now,
|
||||
);
|
||||
|
||||
$this->userRepository->save($user);
|
||||
|
||||
foreach ($resolvedSubjectIds as $subjectId) {
|
||||
foreach ($resolvedClassIds as $classId) {
|
||||
$assignment = TeacherAssignment::creer(
|
||||
tenantId: $tenantId,
|
||||
teacherId: $user->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
academicYearId: $academicYearId,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->teacherAssignmentRepository->save($assignment);
|
||||
}
|
||||
}
|
||||
|
||||
$this->connection->commit();
|
||||
|
||||
foreach ($user->pullDomainEvents() as $event) {
|
||||
$this->eventBus->dispatch($event);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
++$importedCount;
|
||||
} catch (DomainException $e) {
|
||||
$this->logger->warning('Import enseignant ligne {line} échouée : {message}', [
|
||||
'line' => $row->lineNumber,
|
||||
'message' => $e->getMessage(),
|
||||
'batch_id' => $command->batchId,
|
||||
]);
|
||||
++$errorCount;
|
||||
}
|
||||
|
||||
++$processedCount;
|
||||
if ($processedCount % 50 === 0) {
|
||||
$batch->mettreAJourProgression($importedCount, $errorCount);
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->terminer($importedCount, $errorCount, $this->clock->now());
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
} catch (Throwable $e) {
|
||||
$batch->echouer($errorCount, $this->clock->now());
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute les affectations manquantes pour un enseignant existant.
|
||||
*
|
||||
* @param list<SubjectId> $subjectIds
|
||||
* @param list<ClassId> $classIds
|
||||
*/
|
||||
private function addMissingAssignments(
|
||||
User $teacher,
|
||||
array $subjectIds,
|
||||
array $classIds,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
DateTimeImmutable $now,
|
||||
): void {
|
||||
foreach ($subjectIds as $subjectId) {
|
||||
foreach ($classIds as $classId) {
|
||||
$existing = $this->teacherAssignmentRepository->findByTeacherClassSubject(
|
||||
$teacher->id,
|
||||
$classId,
|
||||
$subjectId,
|
||||
$academicYearId,
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
if ($existing !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$assignment = TeacherAssignment::creer(
|
||||
tenantId: $tenantId,
|
||||
teacherId: $teacher->id,
|
||||
classId: $classId,
|
||||
subjectId: $subjectId,
|
||||
academicYearId: $academicYearId,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->teacherAssignmentRepository->save($assignment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $subjectNames
|
||||
* @param array<string, SubjectId> $cache
|
||||
* @param list<string> $existingCodes
|
||||
* @param list<string> $newlyCreatedCodes
|
||||
*
|
||||
* @return list<SubjectId>
|
||||
*/
|
||||
private function resolveSubjectIds(
|
||||
array $subjectNames,
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
bool $createMissing,
|
||||
DateTimeImmutable $now,
|
||||
array &$cache,
|
||||
array &$existingCodes,
|
||||
array &$newlyCreatedCodes,
|
||||
): array {
|
||||
$ids = [];
|
||||
|
||||
foreach ($subjectNames as $name) {
|
||||
if (isset($cache[$name])) {
|
||||
$ids[] = $cache[$name];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($createMissing) {
|
||||
$subjectId = $this->createSubject($name, $tenantId, $schoolId, $now, $existingCodes, $newlyCreatedCodes);
|
||||
$cache[$name] = $subjectId;
|
||||
$ids[] = $subjectId;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $classNames
|
||||
* @param array<string, ClassId> $cache
|
||||
*
|
||||
* @return list<ClassId>
|
||||
*/
|
||||
private function resolveClassIds(
|
||||
array $classNames,
|
||||
TenantId $tenantId,
|
||||
AcademicYearId $academicYearId,
|
||||
array &$cache,
|
||||
): array {
|
||||
$ids = [];
|
||||
|
||||
foreach ($classNames as $name) {
|
||||
if (isset($cache[$name])) {
|
||||
$ids[] = $cache[$name];
|
||||
continue;
|
||||
}
|
||||
|
||||
$classNameVO = new ClassName($name);
|
||||
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
|
||||
|
||||
if ($class !== null) {
|
||||
$cache[$name] = $class->id;
|
||||
$ids[] = $class->id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $existingCodes
|
||||
* @param list<string> $newlyCreatedCodes
|
||||
*/
|
||||
private function createSubject(
|
||||
string $name,
|
||||
TenantId $tenantId,
|
||||
SchoolId $schoolId,
|
||||
DateTimeImmutable $now,
|
||||
array &$existingCodes,
|
||||
array &$newlyCreatedCodes,
|
||||
): SubjectId {
|
||||
if (trim($name) === '') {
|
||||
throw new DomainException('Le nom de la matière ne peut pas être vide.');
|
||||
}
|
||||
|
||||
$code = $this->generateUniqueSubjectCode($name, $existingCodes, $newlyCreatedCodes);
|
||||
|
||||
if ($code === '') {
|
||||
throw new DomainException(sprintf('Impossible de générer un code pour la matière "%s".', $name));
|
||||
}
|
||||
|
||||
$subject = Subject::creer(
|
||||
tenantId: $tenantId,
|
||||
schoolId: $schoolId,
|
||||
name: new SubjectName($name),
|
||||
code: new SubjectCode($code),
|
||||
color: null,
|
||||
createdAt: $now,
|
||||
);
|
||||
|
||||
$this->subjectRepository->save($subject);
|
||||
$newlyCreatedCodes[] = $code;
|
||||
|
||||
return $subject->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $existingCodes
|
||||
* @param list<string> $newlyCreatedCodes
|
||||
*/
|
||||
private function generateUniqueSubjectCode(string $name, array $existingCodes, array $newlyCreatedCodes): string
|
||||
{
|
||||
$base = strtoupper(substr(trim($name), 0, 4));
|
||||
|
||||
if (mb_strlen($base) < 2) {
|
||||
$base .= 'XX';
|
||||
}
|
||||
|
||||
$allCodes = [...$existingCodes, ...$newlyCreatedCodes];
|
||||
|
||||
if (!in_array($base, $allCodes, true)) {
|
||||
return $base;
|
||||
}
|
||||
|
||||
for ($i = 2; $i <= 99; ++$i) {
|
||||
$candidate = $base . $i;
|
||||
if (!in_array($candidate, $allCodes, true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
|
||||
use function mb_strtolower;
|
||||
use function sprintf;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Détecte les doublons parmi les lignes d'import :
|
||||
* - contre les élèves existants en base (email, numéro élève, nom+prénom+classe)
|
||||
* - au sein du fichier lui-même (lignes identiques).
|
||||
*/
|
||||
final readonly class DuplicateDetector
|
||||
{
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
* @param list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}> $existingStudents
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function detecter(array $rows, array $existingStudents): array
|
||||
{
|
||||
$byEmail = [];
|
||||
$byStudentNumber = [];
|
||||
$byNameClass = [];
|
||||
|
||||
foreach ($existingStudents as $student) {
|
||||
if ($student['email'] !== null && trim($student['email']) !== '') {
|
||||
$byEmail[mb_strtolower(trim($student['email']))] = true;
|
||||
}
|
||||
if ($student['studentNumber'] !== null && trim($student['studentNumber']) !== '') {
|
||||
$byStudentNumber[trim($student['studentNumber'])] = true;
|
||||
}
|
||||
$key = $this->nameClassKey(
|
||||
$student['firstName'],
|
||||
$student['lastName'],
|
||||
$student['className'] ?? '',
|
||||
);
|
||||
if ($key !== null) {
|
||||
$byNameClass[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$match = $this->findMatch($row, $byEmail, $byStudentNumber, $byNameClass);
|
||||
|
||||
if ($match !== null) {
|
||||
$row = $row->avecErreurs(new ImportRowError(
|
||||
'_duplicate',
|
||||
sprintf('Cet élève existe déjà (correspondance : %s).', $match),
|
||||
));
|
||||
} else {
|
||||
$this->indexRow($row, $byEmail, $byStudentNumber, $byNameClass);
|
||||
}
|
||||
|
||||
$result[] = $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $byEmail
|
||||
* @param array<string, true> $byStudentNumber
|
||||
* @param array<string, true> $byNameClass
|
||||
*/
|
||||
private function findMatch(
|
||||
ImportRow $row,
|
||||
array $byEmail,
|
||||
array $byStudentNumber,
|
||||
array $byNameClass,
|
||||
): ?string {
|
||||
$email = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
if ($email !== null && trim($email) !== '') {
|
||||
$normalized = mb_strtolower(trim($email));
|
||||
if (isset($byEmail[$normalized])) {
|
||||
return 'email';
|
||||
}
|
||||
}
|
||||
|
||||
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
|
||||
if ($studentNumber !== null && trim($studentNumber) !== '') {
|
||||
if (isset($byStudentNumber[trim($studentNumber)])) {
|
||||
return 'numéro élève';
|
||||
}
|
||||
}
|
||||
|
||||
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
|
||||
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
|
||||
|
||||
if ($key !== null && isset($byNameClass[$key])) {
|
||||
return 'nom + classe';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, true> $byEmail
|
||||
* @param array<string, true> $byStudentNumber
|
||||
* @param array<string, true> $byNameClass
|
||||
*/
|
||||
private function indexRow(
|
||||
ImportRow $row,
|
||||
array &$byEmail,
|
||||
array &$byStudentNumber,
|
||||
array &$byNameClass,
|
||||
): void {
|
||||
$email = $row->valeurChamp(StudentImportField::EMAIL);
|
||||
if ($email !== null && trim($email) !== '') {
|
||||
$byEmail[mb_strtolower(trim($email))] = true;
|
||||
}
|
||||
|
||||
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
|
||||
if ($studentNumber !== null && trim($studentNumber) !== '') {
|
||||
$byStudentNumber[trim($studentNumber)] = true;
|
||||
}
|
||||
|
||||
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
|
||||
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
|
||||
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
|
||||
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
|
||||
|
||||
if ($key !== null) {
|
||||
$byNameClass[$key] = true;
|
||||
}
|
||||
}
|
||||
|
||||
private function nameClassKey(string $firstName, string $lastName, string $className): ?string
|
||||
{
|
||||
$first = mb_strtolower(trim($firstName));
|
||||
$last = mb_strtolower(trim($lastName));
|
||||
$class = mb_strtolower(trim($className));
|
||||
|
||||
if ($first === '' || $last === '' || $class === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $last . '|' . $first . '|' . $class;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Charge les élèves existants d'un tenant pour une année scolaire,
|
||||
* avec leur affectation de classe, afin de détecter les doublons à l'import.
|
||||
*/
|
||||
final readonly class ExistingStudentFinder
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}>
|
||||
*/
|
||||
public function findAllForTenant(TenantId $tenantId, AcademicYearId $academicYearId): array
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
SELECT u.first_name, u.last_name, u.email, u.student_number, sc.name AS class_name
|
||||
FROM users u
|
||||
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
|
||||
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
|
||||
WHERE u.tenant_id = :tenant_id
|
||||
AND u.roles::jsonb @> :role
|
||||
SQL;
|
||||
|
||||
/** @var list<array{first_name: string, last_name: string, email: ?string, student_number: ?string, class_name: ?string}> $rows */
|
||||
$rows = $this->connection->fetchAllAssociative($sql, [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'academic_year_id' => (string) $academicYearId,
|
||||
'role' => '"ROLE_ELEVE"',
|
||||
]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row) => [
|
||||
'firstName' => $row['first_name'],
|
||||
'lastName' => $row['last_name'],
|
||||
'email' => $row['email'],
|
||||
'studentNumber' => $row['student_number'],
|
||||
'className' => $row['class_name'],
|
||||
],
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
/**
|
||||
* Charge les enseignants existants d'un tenant
|
||||
* afin de détecter les doublons à l'import.
|
||||
*/
|
||||
final readonly class ExistingTeacherFinder
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{firstName: string, lastName: string, email: string}>
|
||||
*/
|
||||
public function findAllForTenant(TenantId $tenantId): array
|
||||
{
|
||||
$sql = <<<'SQL'
|
||||
SELECT u.first_name, u.last_name, u.email
|
||||
FROM users u
|
||||
WHERE u.tenant_id = :tenant_id
|
||||
AND u.roles::jsonb @> :role
|
||||
SQL;
|
||||
|
||||
/** @var list<array{first_name: string, last_name: string, email: string}> $rows */
|
||||
$rows = $this->connection->fetchAllAssociative($sql, [
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'role' => '"ROLE_PROF"',
|
||||
]);
|
||||
|
||||
return array_map(
|
||||
static fn (array $row) => [
|
||||
'firstName' => $row['first_name'],
|
||||
'lastName' => $row['last_name'],
|
||||
'email' => $row['email'],
|
||||
],
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
use function explode;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Parse une valeur multi-éléments séparés par un délimiteur.
|
||||
*
|
||||
* Utilisé pour les champs matières et classes dans l'import enseignants
|
||||
* où une cellule CSV peut contenir "Mathématiques, Physique".
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
final readonly class MultiValueParser
|
||||
{
|
||||
/**
|
||||
* @param non-empty-string $separator
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function parse(string $value, string $separator = ','): array
|
||||
{
|
||||
if (trim($value) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
array_map(
|
||||
static fn (string $item): string => trim($item),
|
||||
explode($separator, $value),
|
||||
),
|
||||
static fn (string $item): bool => $item !== '',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,14 @@ use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
use InvalidArgumentException;
|
||||
@@ -34,6 +36,8 @@ final readonly class StudentImportOrchestrator
|
||||
private ClassRepository $classRepository,
|
||||
private ImportBatchRepository $importBatchRepository,
|
||||
private SavedColumnMappingRepository $savedMappingRepository,
|
||||
private ExistingStudentFinder $existingStudentFinder,
|
||||
private DuplicateDetector $duplicateDetector,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
@@ -97,12 +101,18 @@ final readonly class StudentImportOrchestrator
|
||||
*
|
||||
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownClasses: list<string>}
|
||||
*/
|
||||
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId): array
|
||||
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId, ?AcademicYearId $academicYearId = null): array
|
||||
{
|
||||
$existingClasses = $this->getExistingClassNames($tenantId);
|
||||
$validator = new ImportRowValidator($existingClasses);
|
||||
|
||||
$validatedRows = $validator->validerTout($batch->lignes());
|
||||
|
||||
if ($academicYearId !== null) {
|
||||
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
|
||||
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingStudents);
|
||||
}
|
||||
|
||||
$batch->enregistrerLignes($validatedRows);
|
||||
$this->importBatchRepository->save($batch);
|
||||
|
||||
@@ -122,15 +132,31 @@ final readonly class StudentImportOrchestrator
|
||||
*
|
||||
* Quand createMissingClasses est activé, les erreurs de classe inconnue
|
||||
* sont retirées en re-validant sans vérification de classe.
|
||||
* La détection de doublons est ré-appliquée après re-validation
|
||||
* pour ne pas perdre les erreurs _duplicate.
|
||||
*/
|
||||
public function prepareForConfirmation(
|
||||
StudentImportBatch $batch,
|
||||
bool $createMissingClasses,
|
||||
bool $importValidOnly,
|
||||
TenantId $tenantId,
|
||||
?AcademicYearId $academicYearId = null,
|
||||
): void {
|
||||
if ($createMissingClasses) {
|
||||
$validator = new ImportRowValidator();
|
||||
$revalidated = $validator->validerTout($batch->lignes());
|
||||
// Strip old errors before re-validating — the previous validation
|
||||
// may have added className errors that we no longer want.
|
||||
$cleanRows = array_map(
|
||||
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
|
||||
$batch->lignes(),
|
||||
);
|
||||
$revalidated = $validator->validerTout($cleanRows);
|
||||
|
||||
if ($academicYearId !== null) {
|
||||
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
|
||||
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingStudents);
|
||||
}
|
||||
|
||||
$batch->enregistrerLignes($revalidated);
|
||||
}
|
||||
|
||||
@@ -169,8 +195,10 @@ final readonly class StudentImportOrchestrator
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que les colonnes du mapping sauvegardé correspondent
|
||||
* aux colonnes détectées dans le fichier.
|
||||
* Vérifie que le mapping sauvegardé correspond exactement aux colonnes du fichier.
|
||||
*
|
||||
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
|
||||
* mais ne le sont pas par le mapping sauvegardé.
|
||||
*
|
||||
* @param array<string, StudentImportField> $mapping
|
||||
* @param list<string> $columns
|
||||
@@ -183,6 +211,11 @@ final readonly class StudentImportOrchestrator
|
||||
}
|
||||
}
|
||||
|
||||
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
if (count($autoMapping) > count($mapping)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Suggère un mapping automatique des colonnes pour l'import enseignants.
|
||||
*
|
||||
* @see AC2: Mapping spécifique enseignants
|
||||
*/
|
||||
final readonly class TeacherColumnMappingSuggester
|
||||
{
|
||||
/**
|
||||
* Mapping générique par mots-clés pour les enseignants.
|
||||
*
|
||||
* @var array<string, TeacherImportField>
|
||||
*/
|
||||
private const array GENERIC_KEYWORDS = [
|
||||
'nom' => TeacherImportField::LAST_NAME,
|
||||
'last' => TeacherImportField::LAST_NAME,
|
||||
'family' => TeacherImportField::LAST_NAME,
|
||||
'surname' => TeacherImportField::LAST_NAME,
|
||||
'prénom' => TeacherImportField::FIRST_NAME,
|
||||
'prenom' => TeacherImportField::FIRST_NAME,
|
||||
'first' => TeacherImportField::FIRST_NAME,
|
||||
'given' => TeacherImportField::FIRST_NAME,
|
||||
'email' => TeacherImportField::EMAIL,
|
||||
'mail' => TeacherImportField::EMAIL,
|
||||
'courriel' => TeacherImportField::EMAIL,
|
||||
'matière' => TeacherImportField::SUBJECTS,
|
||||
'matiere' => TeacherImportField::SUBJECTS,
|
||||
'matières' => TeacherImportField::SUBJECTS,
|
||||
'matieres' => TeacherImportField::SUBJECTS,
|
||||
'subject' => TeacherImportField::SUBJECTS,
|
||||
'discipline' => TeacherImportField::SUBJECTS,
|
||||
'classe' => TeacherImportField::CLASSES,
|
||||
'classes' => TeacherImportField::CLASSES,
|
||||
'class' => TeacherImportField::CLASSES,
|
||||
'groupe' => TeacherImportField::CLASSES,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param list<string> $columns Colonnes détectées dans le fichier
|
||||
* @param KnownImportFormat $detectedFormat Format détecté
|
||||
*
|
||||
* @return array<string, TeacherImportField> Mapping suggéré (colonne → champ)
|
||||
*/
|
||||
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
|
||||
{
|
||||
return $this->mapperGenerique($columns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, TeacherImportField>
|
||||
*/
|
||||
private function mapperGenerique(array $columns): array
|
||||
{
|
||||
$mapping = [];
|
||||
$usedFields = [];
|
||||
|
||||
foreach ($columns as $column) {
|
||||
$normalized = $this->normaliser($column);
|
||||
|
||||
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
|
||||
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
|
||||
$mapping[$column] = $field;
|
||||
$usedFields[] = $field;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping;
|
||||
}
|
||||
|
||||
private function normaliser(string $column): string
|
||||
{
|
||||
$normalized = mb_strtolower(trim($column));
|
||||
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
|
||||
|
||||
/** @var string $result */
|
||||
$result = preg_replace('/\s+/', ' ', $normalized);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
|
||||
use function mb_strtolower;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Détecte les doublons parmi les lignes d'import enseignants :
|
||||
* - contre les enseignants existants en base (par email)
|
||||
* - au sein du fichier lui-même (lignes avec le même email).
|
||||
*/
|
||||
final readonly class TeacherDuplicateDetector
|
||||
{
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
* @param list<array{firstName: string, lastName: string, email: string}> $existingTeachers
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function detecter(array $rows, array $existingTeachers): array
|
||||
{
|
||||
/** @var array<string, true> $byEmail */
|
||||
$byEmail = [];
|
||||
|
||||
foreach ($existingTeachers as $teacher) {
|
||||
if (trim($teacher['email']) !== '') {
|
||||
$byEmail[mb_strtolower(trim($teacher['email']))] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$email = $row->mappedData[TeacherImportField::EMAIL->value] ?? null;
|
||||
|
||||
if ($email !== null && trim($email) !== '') {
|
||||
$normalized = mb_strtolower(trim($email));
|
||||
|
||||
if (isset($byEmail[$normalized])) {
|
||||
$row = $row->avecErreurs(new ImportRowError(
|
||||
'_duplicate',
|
||||
'Cet enseignant existe déjà (correspondance : email).',
|
||||
));
|
||||
} else {
|
||||
$byEmail[$normalized] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = $row;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportBatch;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Administration\Domain\Repository\ClassRepository;
|
||||
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
|
||||
use App\Administration\Domain\Repository\SubjectRepository;
|
||||
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
|
||||
use App\Shared\Domain\Clock;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Orchestre la chaîne d'import d'enseignants : parse → détection → mapping → validation.
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
final readonly class TeacherImportOrchestrator
|
||||
{
|
||||
public function __construct(
|
||||
private CsvParser $csvParser,
|
||||
private XlsxParser $xlsxParser,
|
||||
private ImportFormatDetector $formatDetector,
|
||||
private TeacherColumnMappingSuggester $mappingSuggester,
|
||||
private SubjectRepository $subjectRepository,
|
||||
private ClassRepository $classRepository,
|
||||
private TeacherImportBatchRepository $teacherImportBatchRepository,
|
||||
private SavedTeacherColumnMappingRepository $savedMappingRepository,
|
||||
private ExistingTeacherFinder $existingTeacherFinder,
|
||||
private TeacherDuplicateDetector $duplicateDetector,
|
||||
private Clock $clock,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse un fichier uploadé : parse, détecte le format, suggère un mapping,
|
||||
* crée le batch et enregistre les lignes mappées.
|
||||
*
|
||||
* @return array{batch: TeacherImportBatch, suggestedMapping: array<string, TeacherImportField>}
|
||||
*/
|
||||
public function analyzeFile(string $filePath, string $extension, string $originalFilename, TenantId $tenantId): array
|
||||
{
|
||||
$parseResult = match ($extension) {
|
||||
'csv', 'txt' => $this->csvParser->parse($filePath),
|
||||
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
|
||||
default => throw new InvalidArgumentException('Format non supporté. Utilisez CSV ou XLSX.'),
|
||||
};
|
||||
|
||||
$detectedFormat = $this->formatDetector->detecter($parseResult->columns);
|
||||
$suggestedMapping = $this->suggestMapping($parseResult->columns, $detectedFormat, $tenantId);
|
||||
|
||||
$batch = TeacherImportBatch::creer(
|
||||
tenantId: $tenantId,
|
||||
originalFilename: $originalFilename,
|
||||
totalRows: $parseResult->totalRows(),
|
||||
detectedColumns: $parseResult->columns,
|
||||
detectedFormat: $detectedFormat,
|
||||
createdAt: $this->clock->now(),
|
||||
);
|
||||
|
||||
$rows = $this->mapRows($parseResult, $suggestedMapping);
|
||||
$batch->enregistrerLignes($rows);
|
||||
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
|
||||
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
|
||||
*/
|
||||
public function applyMapping(TeacherImportBatch $batch, TeacherColumnMapping $columnMapping): void
|
||||
{
|
||||
$batch->appliquerMapping($columnMapping);
|
||||
|
||||
$remapped = [];
|
||||
foreach ($batch->lignes() as $row) {
|
||||
$mappedData = [];
|
||||
foreach ($columnMapping->mapping as $column => $field) {
|
||||
$mappedData[$field->value] = $row->rawData[$column] ?? '';
|
||||
}
|
||||
$remapped[] = new ImportRow($row->lineNumber, $row->rawData, $mappedData);
|
||||
}
|
||||
$batch->enregistrerLignes($remapped);
|
||||
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les lignes du batch et retourne les résultats avec les matières et classes inconnues.
|
||||
*
|
||||
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownSubjects: list<string>, unknownClasses: list<string>}
|
||||
*/
|
||||
public function generatePreview(TeacherImportBatch $batch, TenantId $tenantId): array
|
||||
{
|
||||
$existingSubjects = $this->getExistingSubjectNames($tenantId);
|
||||
$existingClasses = $this->getExistingClassNames($tenantId);
|
||||
$validator = new TeacherImportRowValidator($existingSubjects, $existingClasses);
|
||||
|
||||
$validatedRows = $validator->validerTout($batch->lignes());
|
||||
|
||||
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
|
||||
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingTeachers);
|
||||
|
||||
$batch->enregistrerLignes($validatedRows);
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
|
||||
$report = ImportReport::fromValidatedRows($validatedRows);
|
||||
$unknownSubjects = $this->detectUnknownValues($validatedRows, TeacherImportField::SUBJECTS, $existingSubjects);
|
||||
$unknownClasses = $this->detectUnknownValues($validatedRows, TeacherImportField::CLASSES, $existingClasses);
|
||||
|
||||
return [
|
||||
'validatedRows' => $validatedRows,
|
||||
'report' => $report,
|
||||
'unknownSubjects' => $unknownSubjects,
|
||||
'unknownClasses' => $unknownClasses,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prépare le batch pour la confirmation : re-valide si nécessaire
|
||||
* et filtre les lignes selon les options choisies par l'utilisateur.
|
||||
*
|
||||
* La détection de doublons est ré-appliquée après re-validation
|
||||
* pour ne pas perdre les erreurs _duplicate.
|
||||
*/
|
||||
public function prepareForConfirmation(
|
||||
TeacherImportBatch $batch,
|
||||
bool $createMissingSubjects,
|
||||
bool $importValidOnly,
|
||||
TenantId $tenantId,
|
||||
): void {
|
||||
if ($createMissingSubjects) {
|
||||
$validator = new TeacherImportRowValidator();
|
||||
// Strip old errors before re-validating — the previous validation
|
||||
// may have added subject/class errors that we no longer want.
|
||||
$cleanRows = array_map(
|
||||
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
|
||||
$batch->lignes(),
|
||||
);
|
||||
$revalidated = $validator->validerTout($cleanRows);
|
||||
|
||||
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
|
||||
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingTeachers);
|
||||
|
||||
$batch->enregistrerLignes($revalidated);
|
||||
}
|
||||
|
||||
if ($importValidOnly) {
|
||||
$batch->enregistrerLignes($batch->lignesValides());
|
||||
}
|
||||
|
||||
if ($batch->mapping !== null) {
|
||||
$this->savedMappingRepository->save(
|
||||
$batch->tenantId,
|
||||
$batch->mapping->format,
|
||||
$batch->mapping->mapping,
|
||||
);
|
||||
}
|
||||
|
||||
$this->teacherImportBatchRepository->save($batch);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $columns
|
||||
*
|
||||
* @return array<string, TeacherImportField>
|
||||
*/
|
||||
private function suggestMapping(array $columns, KnownImportFormat $format, TenantId $tenantId): array
|
||||
{
|
||||
$saved = $this->savedMappingRepository->findByTenantAndFormat($tenantId, $format);
|
||||
|
||||
if ($saved !== null && $this->savedMappingMatchesColumns($saved, $columns)) {
|
||||
return $saved;
|
||||
}
|
||||
|
||||
return $this->mappingSuggester->suggerer($columns, $format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le mapping sauvegardé correspond exactement aux colonnes du fichier.
|
||||
*
|
||||
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
|
||||
* mais ne le sont pas par le mapping sauvegardé (ex: colonne « Matières » absente
|
||||
* d'un mapping sauvegardé à 3 colonnes).
|
||||
*
|
||||
* @param array<string, TeacherImportField> $mapping
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function savedMappingMatchesColumns(array $mapping, array $columns): bool
|
||||
{
|
||||
foreach (array_keys($mapping) as $column) {
|
||||
if (!in_array($column, $columns, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject saved mapping if file has more columns than the mapping covers:
|
||||
// the auto-detection might map them and the user expects to see them.
|
||||
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
|
||||
if (count($autoMapping) > count($mapping)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, TeacherImportField> $mapping
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
private function mapRows(FileParseResult $parseResult, array $mapping): array
|
||||
{
|
||||
$rows = [];
|
||||
$lineNumber = 1;
|
||||
|
||||
foreach ($parseResult->rows as $rawData) {
|
||||
$mappedData = [];
|
||||
foreach ($mapping as $column => $field) {
|
||||
$mappedData[$field->value] = $rawData[$column] ?? '';
|
||||
}
|
||||
|
||||
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
|
||||
++$lineNumber;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getExistingSubjectNames(TenantId $tenantId): array
|
||||
{
|
||||
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn ($subject) => (string) $subject->name,
|
||||
$subjects,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function getExistingClassNames(TenantId $tenantId): array
|
||||
{
|
||||
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn ($class) => (string) $class->name,
|
||||
$classes,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
* @param list<string> $existingValues
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function detectUnknownValues(array $rows, TeacherImportField $field, array $existingValues): array
|
||||
{
|
||||
$parser = new MultiValueParser();
|
||||
$unknown = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$raw = $row->mappedData[$field->value] ?? null;
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($parser->parse($raw) as $value) {
|
||||
if (!in_array($value, $existingValues, true)
|
||||
&& !in_array($value, $unknown, true)
|
||||
) {
|
||||
$unknown[] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Application\Service\Import;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
|
||||
use const FILTER_VALIDATE_EMAIL;
|
||||
|
||||
use function in_array;
|
||||
use function sprintf;
|
||||
use function trim;
|
||||
|
||||
/**
|
||||
* Valide les lignes d'import enseignants après mapping.
|
||||
*
|
||||
* Vérifie les champs obligatoires (nom, prénom, email),
|
||||
* le format email, et l'existence des matières/classes référencées.
|
||||
*
|
||||
* @see AC2: Mapping spécifique enseignants
|
||||
* @see AC3: Gestion matières inexistantes
|
||||
* @see AC5: Gestion doublons email
|
||||
*/
|
||||
final readonly class TeacherImportRowValidator
|
||||
{
|
||||
private MultiValueParser $multiValueParser;
|
||||
|
||||
/**
|
||||
* @param list<string>|null $existingSubjectNames Noms des matières existantes. null = pas de vérification.
|
||||
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
|
||||
*/
|
||||
public function __construct(
|
||||
private ?array $existingSubjectNames = null,
|
||||
private ?array $existingClassNames = null,
|
||||
) {
|
||||
$this->multiValueParser = new MultiValueParser();
|
||||
}
|
||||
|
||||
public function valider(ImportRow $row): ImportRow
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
|
||||
$errors = [...$errors, ...$this->validerEmail($row)];
|
||||
$errors = [...$errors, ...$this->validerMatieres($row)];
|
||||
$errors = [...$errors, ...$this->validerClasses($row)];
|
||||
|
||||
if ($errors !== []) {
|
||||
return $row->avecErreurs(...$errors);
|
||||
}
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
*
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function validerTout(array $rows): array
|
||||
{
|
||||
return array_map($this->valider(...), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerChampsObligatoires(ImportRow $row): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach (TeacherImportField::champsObligatoires() as $field) {
|
||||
$value = $row->mappedData[$field->value] ?? null;
|
||||
|
||||
if ($value === null || trim($value) === '') {
|
||||
$errors[] = new ImportRowError(
|
||||
$field->value,
|
||||
sprintf('Le champ "%s" est obligatoire.', $field->label()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerEmail(ImportRow $row): array
|
||||
{
|
||||
$email = $row->mappedData[TeacherImportField::EMAIL->value] ?? null;
|
||||
|
||||
if ($email === null || trim($email) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
|
||||
return [new ImportRowError(
|
||||
TeacherImportField::EMAIL->value,
|
||||
sprintf('L\'adresse email "%s" est invalide.', $email),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerMatieres(ImportRow $row): array
|
||||
{
|
||||
if ($this->existingSubjectNames === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? null;
|
||||
|
||||
if ($subjectsRaw === null || trim($subjectsRaw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjects = $this->multiValueParser->parse($subjectsRaw);
|
||||
$unknown = [];
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
if (!in_array($subject, $this->existingSubjectNames, true)) {
|
||||
$unknown[] = $subject;
|
||||
}
|
||||
}
|
||||
|
||||
if ($unknown !== []) {
|
||||
return [new ImportRowError(
|
||||
TeacherImportField::SUBJECTS->value,
|
||||
sprintf('Matière(s) inexistante(s) : %s', implode(', ', $unknown)),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRowError>
|
||||
*/
|
||||
private function validerClasses(ImportRow $row): array
|
||||
{
|
||||
if ($this->existingClassNames === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? null;
|
||||
|
||||
if ($classesRaw === null || trim($classesRaw) === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$classes = $this->multiValueParser->parse($classesRaw);
|
||||
$unknown = [];
|
||||
|
||||
foreach ($classes as $class) {
|
||||
if (!in_array($class, $this->existingClassNames, true)) {
|
||||
$unknown[] = $class;
|
||||
}
|
||||
}
|
||||
|
||||
if ($unknown !== []) {
|
||||
return [new ImportRowError(
|
||||
TeacherImportField::CLASSES->value,
|
||||
sprintf('Classe(s) inexistante(s) : %s', implode(', ', $unknown)),
|
||||
)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'enseignants échoue.
|
||||
*/
|
||||
final readonly class ImportEnseignantsEchoue implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $errorCount,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'enseignants est lancé.
|
||||
*/
|
||||
final readonly class ImportEnseignantsLance implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $totalRows,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Event;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Shared\Domain\DomainEvent;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Override;
|
||||
use Ramsey\Uuid\UuidInterface;
|
||||
|
||||
/**
|
||||
* Événement émis lorsqu'un import d'enseignants est terminé avec succès.
|
||||
*/
|
||||
final readonly class ImportEnseignantsTermine implements DomainEvent
|
||||
{
|
||||
public function __construct(
|
||||
public ImportBatchId $batchId,
|
||||
public TenantId $tenantId,
|
||||
public int $importedCount,
|
||||
public int $errorCount,
|
||||
private DateTimeImmutable $occurredOn,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function occurredOn(): DateTimeImmutable
|
||||
{
|
||||
return $this->occurredOn;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function aggregateId(): UuidInterface
|
||||
{
|
||||
return $this->batchId->value;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Administration\Domain\Exception;
|
||||
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use DomainException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final class MappingIncompletException extends DomainException
|
||||
{
|
||||
public static function champManquant(StudentImportField $champ): self
|
||||
public static function champManquant(StudentImportField|TeacherImportField $champ): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Le champ obligatoire "%s" (%s) n\'est pas mappé.',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\Import;
|
||||
|
||||
use App\Administration\Domain\Exception\MappingIncompletException;
|
||||
|
||||
use function in_array;
|
||||
|
||||
/**
|
||||
* Value Object représentant l'association entre les colonnes du fichier source
|
||||
* et les champs Classeo pour l'import enseignants.
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
final readonly class TeacherColumnMapping
|
||||
{
|
||||
/**
|
||||
* @param array<string, TeacherImportField> $mapping Colonne source → champ Classeo
|
||||
*/
|
||||
private function __construct(
|
||||
public array $mapping,
|
||||
public KnownImportFormat $format,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, TeacherImportField> $mapping Colonne source → champ Classeo
|
||||
*/
|
||||
public static function creer(array $mapping, KnownImportFormat $format): self
|
||||
{
|
||||
$mappedFields = array_values($mapping);
|
||||
$champsObligatoires = TeacherImportField::champsObligatoires();
|
||||
|
||||
foreach ($champsObligatoires as $champ) {
|
||||
if (!in_array($champ, $mappedFields, true)) {
|
||||
throw MappingIncompletException::champManquant($champ);
|
||||
}
|
||||
}
|
||||
|
||||
return new self($mapping, $format);
|
||||
}
|
||||
|
||||
public function champPour(string $colonneSource): ?TeacherImportField
|
||||
{
|
||||
return $this->mapping[$colonneSource] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function colonnesSources(): array
|
||||
{
|
||||
return array_keys($this->mapping);
|
||||
}
|
||||
|
||||
public function equals(self $other): bool
|
||||
{
|
||||
return $this->mapping === $other->mapping && $this->format === $other->format;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\Import;
|
||||
|
||||
use App\Administration\Domain\Event\ImportEnseignantsEchoue;
|
||||
use App\Administration\Domain\Event\ImportEnseignantsLance;
|
||||
use App\Administration\Domain\Event\ImportEnseignantsTermine;
|
||||
use App\Administration\Domain\Exception\ImportNonDemarrableException;
|
||||
use App\Shared\Domain\AggregateRoot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_filter;
|
||||
use function array_values;
|
||||
|
||||
use DateTimeImmutable;
|
||||
|
||||
use function min;
|
||||
|
||||
/**
|
||||
* Aggregate Root représentant un lot d'import d'enseignants.
|
||||
*
|
||||
* Gère le cycle de vie d'un import depuis l'upload du fichier
|
||||
* jusqu'à la confirmation finale.
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
final class TeacherImportBatch extends AggregateRoot
|
||||
{
|
||||
public private(set) ?TeacherColumnMapping $mapping = null;
|
||||
public private(set) int $importedCount = 0;
|
||||
public private(set) int $errorCount = 0;
|
||||
public private(set) ?DateTimeImmutable $completedAt = null;
|
||||
|
||||
/** @var list<ImportRow> */
|
||||
private array $rows = [];
|
||||
|
||||
private function __construct(
|
||||
public private(set) ImportBatchId $id,
|
||||
public private(set) TenantId $tenantId,
|
||||
public private(set) string $originalFilename,
|
||||
public private(set) int $totalRows,
|
||||
/** @var list<string> */
|
||||
public private(set) array $detectedColumns,
|
||||
public private(set) ?KnownImportFormat $detectedFormat,
|
||||
public private(set) ImportStatus $status,
|
||||
public private(set) DateTimeImmutable $createdAt,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $detectedColumns Colonnes détectées dans le fichier
|
||||
*/
|
||||
public static function creer(
|
||||
TenantId $tenantId,
|
||||
string $originalFilename,
|
||||
int $totalRows,
|
||||
array $detectedColumns,
|
||||
?KnownImportFormat $detectedFormat,
|
||||
DateTimeImmutable $createdAt,
|
||||
): self {
|
||||
return new self(
|
||||
id: ImportBatchId::generate(),
|
||||
tenantId: $tenantId,
|
||||
originalFilename: $originalFilename,
|
||||
totalRows: $totalRows,
|
||||
detectedColumns: $detectedColumns,
|
||||
detectedFormat: $detectedFormat,
|
||||
status: ImportStatus::PENDING,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
public function appliquerMapping(TeacherColumnMapping $mapping): void
|
||||
{
|
||||
$this->mapping = $mapping;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
*/
|
||||
public function enregistrerLignes(array $rows): void
|
||||
{
|
||||
$this->rows = $rows;
|
||||
}
|
||||
|
||||
public function demarrer(DateTimeImmutable $at): void
|
||||
{
|
||||
if (!$this->status->peutDemarrer()) {
|
||||
throw ImportNonDemarrableException::pourStatut($this->id, $this->status);
|
||||
}
|
||||
|
||||
if ($this->mapping === null) {
|
||||
throw ImportNonDemarrableException::mappingManquant($this->id);
|
||||
}
|
||||
|
||||
$this->status = ImportStatus::PROCESSING;
|
||||
|
||||
$this->recordEvent(new ImportEnseignantsLance(
|
||||
batchId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
totalRows: $this->totalRows,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
public function mettreAJourProgression(int $importedCount, int $errorCount): void
|
||||
{
|
||||
$this->importedCount = $importedCount;
|
||||
$this->errorCount = $errorCount;
|
||||
}
|
||||
|
||||
public function terminer(int $importedCount, int $errorCount, DateTimeImmutable $at): void
|
||||
{
|
||||
$this->status = ImportStatus::COMPLETED;
|
||||
$this->importedCount = $importedCount;
|
||||
$this->errorCount = $errorCount;
|
||||
$this->completedAt = $at;
|
||||
|
||||
$this->recordEvent(new ImportEnseignantsTermine(
|
||||
batchId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
importedCount: $importedCount,
|
||||
errorCount: $errorCount,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
public function echouer(int $errorCount, DateTimeImmutable $at): void
|
||||
{
|
||||
$this->status = ImportStatus::FAILED;
|
||||
$this->errorCount = $errorCount;
|
||||
$this->completedAt = $at;
|
||||
|
||||
$this->recordEvent(new ImportEnseignantsEchoue(
|
||||
batchId: $this->id,
|
||||
tenantId: $this->tenantId,
|
||||
errorCount: $errorCount,
|
||||
occurredOn: $at,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function lignes(): array
|
||||
{
|
||||
return $this->rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function lignesValides(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->rows,
|
||||
static fn (ImportRow $row): bool => $row->estValide(),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
public function lignesEnErreur(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->rows,
|
||||
static fn (ImportRow $row): bool => !$row->estValide(),
|
||||
));
|
||||
}
|
||||
|
||||
public function estTermine(): bool
|
||||
{
|
||||
return $this->status->estTermine();
|
||||
}
|
||||
|
||||
public function progression(): float
|
||||
{
|
||||
if ($this->totalRows === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
return min(100.0, ($this->importedCount + $this->errorCount) / $this->totalRows * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal Pour usage Infrastructure uniquement
|
||||
*
|
||||
* @param list<string> $detectedColumns
|
||||
*/
|
||||
public static function reconstitute(
|
||||
ImportBatchId $id,
|
||||
TenantId $tenantId,
|
||||
string $originalFilename,
|
||||
int $totalRows,
|
||||
array $detectedColumns,
|
||||
?KnownImportFormat $detectedFormat,
|
||||
ImportStatus $status,
|
||||
?TeacherColumnMapping $mapping,
|
||||
int $importedCount,
|
||||
int $errorCount,
|
||||
DateTimeImmutable $createdAt,
|
||||
?DateTimeImmutable $completedAt,
|
||||
): self {
|
||||
$batch = new self(
|
||||
id: $id,
|
||||
tenantId: $tenantId,
|
||||
originalFilename: $originalFilename,
|
||||
totalRows: $totalRows,
|
||||
detectedColumns: $detectedColumns,
|
||||
detectedFormat: $detectedFormat,
|
||||
status: $status,
|
||||
createdAt: $createdAt,
|
||||
);
|
||||
|
||||
$batch->mapping = $mapping;
|
||||
$batch->importedCount = $importedCount;
|
||||
$batch->errorCount = $errorCount;
|
||||
$batch->completedAt = $completedAt;
|
||||
|
||||
return $batch;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Model\Import;
|
||||
|
||||
/**
|
||||
* Champs Classeo disponibles pour le mapping d'import enseignants.
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
enum TeacherImportField: string
|
||||
{
|
||||
case LAST_NAME = 'lastName';
|
||||
case FIRST_NAME = 'firstName';
|
||||
case EMAIL = 'email';
|
||||
case SUBJECTS = 'subjects';
|
||||
case CLASSES = 'classes';
|
||||
|
||||
public function estObligatoire(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::LAST_NAME, self::FIRST_NAME, self::EMAIL => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function estMultiValeur(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::SUBJECTS, self::CLASSES => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::LAST_NAME => 'Nom',
|
||||
self::FIRST_NAME => 'Prénom',
|
||||
self::EMAIL => 'Email',
|
||||
self::SUBJECTS => 'Matières',
|
||||
self::CLASSES => 'Classes',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<self>
|
||||
*/
|
||||
public static function champsObligatoires(): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::cases(),
|
||||
static fn (self $field): bool => $field->estObligatoire(),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -448,6 +448,15 @@ final class User extends AggregateRoot
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le prénom et le nom de l'utilisateur.
|
||||
*/
|
||||
public function mettreAJourInfos(string $firstName, string $lastName): void
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
$this->lastName = $lastName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's password.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface SavedTeacherColumnMappingRepository
|
||||
{
|
||||
/**
|
||||
* @param array<string, TeacherImportField> $mapping
|
||||
*/
|
||||
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void;
|
||||
|
||||
/**
|
||||
* @return array<string, TeacherImportField>|null
|
||||
*/
|
||||
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Domain\Repository;
|
||||
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportBatch;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
interface TeacherImportBatchRepository
|
||||
{
|
||||
public function save(TeacherImportBatch $batch): void;
|
||||
|
||||
public function get(ImportBatchId $id): TeacherImportBatch;
|
||||
|
||||
public function findById(ImportBatchId $id): ?TeacherImportBatch;
|
||||
|
||||
/**
|
||||
* @return list<TeacherImportBatch>
|
||||
*/
|
||||
public function findByTenant(TenantId $tenantId): array;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\StudentImportBatch;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
@@ -175,7 +176,10 @@ final readonly class StudentImportController
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
$batch = $this->getBatch($id, $tenantId);
|
||||
|
||||
$result = $this->orchestrator->generatePreview($batch, $tenantId);
|
||||
$academicYearIdRaw = $this->academicYearResolver->resolve('current');
|
||||
$academicYearId = $academicYearIdRaw !== null ? AcademicYearId::fromString($academicYearIdRaw) : null;
|
||||
|
||||
$result = $this->orchestrator->generatePreview($batch, $tenantId, $academicYearId);
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $batch->id,
|
||||
@@ -194,7 +198,8 @@ final readonly class StudentImportController
|
||||
public function confirm(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
$batch = $this->getBatch($id, $tenantId);
|
||||
|
||||
$data = $request->toArray();
|
||||
|
||||
@@ -208,7 +213,13 @@ final readonly class StudentImportController
|
||||
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
|
||||
$schoolName = $this->tenantContext->getCurrentTenantConfig()->subdomain;
|
||||
|
||||
$this->orchestrator->prepareForConfirmation($batch, $createMissingClasses, $importValidOnly);
|
||||
$this->orchestrator->prepareForConfirmation(
|
||||
$batch,
|
||||
$createMissingClasses,
|
||||
$importValidOnly,
|
||||
$tenantId,
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
);
|
||||
|
||||
$this->commandBus->dispatch(new ImportStudentsCommand(
|
||||
batchId: (string) $batch->id,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand;
|
||||
use App\Administration\Application\Service\Import\ImportReport;
|
||||
use App\Administration\Application\Service\Import\TeacherImportOrchestrator;
|
||||
use App\Administration\Domain\Exception\FichierImportInvalideException;
|
||||
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
|
||||
use App\Administration\Domain\Exception\MappingIncompletException;
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportBatch;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use App\Shared\Infrastructure\Tenant\TenantContext;
|
||||
|
||||
use function array_slice;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
use function in_array;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Messenger\MessageBusInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* Endpoints pour le wizard d'import d'enseignants via CSV/XLSX.
|
||||
*
|
||||
* @see FR77: Import enseignants via CSV
|
||||
*/
|
||||
#[Route('/api/import/teachers')]
|
||||
final readonly class TeacherImportController
|
||||
{
|
||||
private const int MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
|
||||
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private TeacherImportBatchRepository $teacherImportBatchRepository,
|
||||
private TeacherImportOrchestrator $orchestrator,
|
||||
private MessageBusInterface $commandBus,
|
||||
private TenantContext $tenantContext,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* T5.1 : Upload d'un fichier CSV ou XLSX.
|
||||
*
|
||||
* Retourne les colonnes détectées, un aperçu et un mapping suggéré.
|
||||
*/
|
||||
#[Route('/upload', methods: ['POST'], name: 'api_import_teachers_upload')]
|
||||
public function upload(Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
|
||||
$file = $request->files->get('file');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
throw new BadRequestHttpException('Un fichier CSV ou XLSX est requis.');
|
||||
}
|
||||
|
||||
if ($file->getSize() > self::MAX_FILE_SIZE) {
|
||||
throw new BadRequestHttpException('Le fichier dépasse la taille maximale de 10 Mo.');
|
||||
}
|
||||
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
if (!in_array($extension, ['csv', 'txt', 'xlsx', 'xls'], true)) {
|
||||
throw new BadRequestHttpException('Extension non supportée. Utilisez CSV ou XLSX.');
|
||||
}
|
||||
|
||||
$allowedMimeTypes = [
|
||||
'text/csv', 'text/plain', 'application/csv',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
];
|
||||
|
||||
$mimeType = $file->getMimeType();
|
||||
if ($mimeType === null || !in_array($mimeType, $allowedMimeTypes, true)) {
|
||||
throw new BadRequestHttpException('Type de fichier non supporté. Utilisez CSV ou XLSX.');
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->orchestrator->analyzeFile(
|
||||
$file->getPathname(),
|
||||
$extension,
|
||||
$file->getClientOriginalName(),
|
||||
$tenantId,
|
||||
);
|
||||
} catch (FichierImportInvalideException|InvalidArgumentException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
$batch = $result['batch'];
|
||||
$suggestedMapping = $result['suggestedMapping'];
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $batch->id,
|
||||
'filename' => $batch->originalFilename,
|
||||
'totalRows' => $batch->totalRows,
|
||||
'columns' => $batch->detectedColumns,
|
||||
'detectedFormat' => ($batch->detectedFormat ?? KnownImportFormat::CUSTOM)->value,
|
||||
'suggestedMapping' => $this->serializeMapping($suggestedMapping),
|
||||
'preview' => $this->serializeRows(array_slice($batch->lignes(), 0, 5)),
|
||||
], Response::HTTP_CREATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* T5.2 : Valider et appliquer le mapping des colonnes.
|
||||
*/
|
||||
#[Route('/{id}/mapping', methods: ['POST'], name: 'api_import_teachers_mapping')]
|
||||
public function mapping(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||
|
||||
$data = $request->toArray();
|
||||
|
||||
/** @var array<string, string> $mappingData */
|
||||
$mappingData = $data['mapping'] ?? [];
|
||||
|
||||
/** @var string $formatValue */
|
||||
$formatValue = $data['format'] ?? '';
|
||||
$format = KnownImportFormat::tryFrom($formatValue) ?? KnownImportFormat::CUSTOM;
|
||||
|
||||
/** @var array<string, TeacherImportField> $mappingFields */
|
||||
$mappingFields = [];
|
||||
foreach ($mappingData as $column => $fieldValue) {
|
||||
$field = TeacherImportField::tryFrom($fieldValue);
|
||||
if ($field !== null) {
|
||||
$mappingFields[$column] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$columnMapping = TeacherColumnMapping::creer($mappingFields, $format);
|
||||
} catch (MappingIncompletException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage());
|
||||
}
|
||||
|
||||
$this->orchestrator->applyMapping($batch, $columnMapping);
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $batch->id,
|
||||
'mapping' => $this->serializeMapping($columnMapping->mapping),
|
||||
'totalRows' => $batch->totalRows,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* T5.3 : Preview avec validation et erreurs.
|
||||
*/
|
||||
#[Route('/{id}/preview', methods: ['GET'], name: 'api_import_teachers_preview')]
|
||||
public function preview(string $id): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
$batch = $this->getBatch($id, $tenantId);
|
||||
|
||||
$result = $this->orchestrator->generatePreview($batch, $tenantId);
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $batch->id,
|
||||
'totalRows' => $result['report']->totalRows,
|
||||
'validCount' => $result['report']->importedCount,
|
||||
'errorCount' => $result['report']->errorCount,
|
||||
'rows' => $this->serializeRows($result['validatedRows']),
|
||||
'unknownSubjects' => $result['unknownSubjects'],
|
||||
'unknownClasses' => $result['unknownClasses'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* T5.4 : Confirmer et lancer l'import.
|
||||
*/
|
||||
#[Route('/{id}/confirm', methods: ['POST'], name: 'api_import_teachers_confirm')]
|
||||
public function confirm(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$tenantId = TenantId::fromString($user->tenantId());
|
||||
$batch = $this->getBatch($id, $tenantId);
|
||||
|
||||
$data = $request->toArray();
|
||||
|
||||
/** @var bool $createMissingSubjects */
|
||||
$createMissingSubjects = $data['createMissingSubjects'] ?? false;
|
||||
|
||||
/** @var bool $importValidOnly */
|
||||
$importValidOnly = $data['importValidOnly'] ?? true;
|
||||
|
||||
/** @var bool $updateExisting */
|
||||
$updateExisting = $data['updateExisting'] ?? false;
|
||||
|
||||
$academicYearId = $this->academicYearResolver->resolve('current')
|
||||
?? throw new BadRequestHttpException('Impossible de résoudre l\'année scolaire courante.');
|
||||
$schoolName = $this->tenantContext->getCurrentTenantConfig()->subdomain;
|
||||
|
||||
$this->orchestrator->prepareForConfirmation($batch, $createMissingSubjects, $importValidOnly, $tenantId);
|
||||
|
||||
$this->commandBus->dispatch(new ImportTeachersCommand(
|
||||
batchId: (string) $batch->id,
|
||||
tenantId: $user->tenantId(),
|
||||
schoolName: $schoolName,
|
||||
academicYearId: $academicYearId,
|
||||
createMissingSubjects: $createMissingSubjects,
|
||||
updateExisting: $updateExisting,
|
||||
));
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $batch->id,
|
||||
'status' => 'processing',
|
||||
'message' => 'Import lancé. Suivez la progression via GET /status.',
|
||||
], Response::HTTP_ACCEPTED);
|
||||
}
|
||||
|
||||
/**
|
||||
* T5.5 : Statut et progression.
|
||||
*/
|
||||
#[Route('/{id}/status', methods: ['GET'], name: 'api_import_teachers_status')]
|
||||
public function status(string $id): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $batch->id,
|
||||
'status' => $batch->status->value,
|
||||
'totalRows' => $batch->totalRows,
|
||||
'importedCount' => $batch->importedCount,
|
||||
'errorCount' => $batch->errorCount,
|
||||
'progression' => $batch->progression(),
|
||||
'completedAt' => $batch->completedAt?->format(DateTimeInterface::ATOM),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* T5.6 : Télécharger le rapport.
|
||||
*/
|
||||
#[Route('/{id}/report', methods: ['GET'], name: 'api_import_teachers_report')]
|
||||
public function report(string $id): JsonResponse
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
$batch = $this->getBatch($id, TenantId::fromString($user->tenantId()));
|
||||
|
||||
$report = ImportReport::fromValidatedRows($batch->lignes());
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => (string) $batch->id,
|
||||
'status' => $batch->status->value,
|
||||
'totalRows' => $report->totalRows,
|
||||
'importedCount' => $batch->importedCount,
|
||||
'errorCount' => $batch->errorCount,
|
||||
'report' => $report->lignesRapport(),
|
||||
'errors' => array_map(
|
||||
static fn (ImportRow $row) => [
|
||||
'line' => $row->lineNumber,
|
||||
'errors' => array_map(
|
||||
static fn (ImportRowError $error) => [
|
||||
'column' => $error->column,
|
||||
'message' => $error->message,
|
||||
],
|
||||
$row->errors,
|
||||
),
|
||||
],
|
||||
$report->errorRows,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
private function getSecurityUser(): SecurityUser
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new AccessDeniedHttpException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function getBatch(string $id, TenantId $tenantId): TeacherImportBatch
|
||||
{
|
||||
try {
|
||||
$batch = $this->teacherImportBatchRepository->get(ImportBatchId::fromString($id));
|
||||
} catch (ImportBatchNotFoundException|InvalidArgumentException) {
|
||||
throw new NotFoundHttpException('Import non trouvé.');
|
||||
}
|
||||
|
||||
if ((string) $batch->tenantId !== (string) $tenantId) {
|
||||
throw new NotFoundHttpException('Import non trouvé.');
|
||||
}
|
||||
|
||||
return $batch;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, TeacherImportField> $mapping
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function serializeMapping(array $mapping): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($mapping as $column => $field) {
|
||||
$result[$column] = $field->value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function serializeRows(array $rows): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (ImportRow $row) => [
|
||||
'line' => $row->lineNumber,
|
||||
'data' => $row->mappedData,
|
||||
'valid' => $row->estValide(),
|
||||
'errors' => array_map(
|
||||
static fn (ImportRowError $error) => [
|
||||
'column' => $error->column,
|
||||
'message' => $error->message,
|
||||
],
|
||||
$row->errors,
|
||||
),
|
||||
],
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineSavedTeacherColumnMappingRepository implements SavedTeacherColumnMappingRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void
|
||||
{
|
||||
$serialized = [];
|
||||
foreach ($mapping as $column => $field) {
|
||||
$serialized[$column] = $field->value;
|
||||
}
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO saved_teacher_column_mappings (tenant_id, format, mapping_data, saved_at)
|
||||
VALUES (:tenant_id, :format, :mapping_data, :saved_at)
|
||||
ON CONFLICT (tenant_id, format) DO UPDATE SET
|
||||
mapping_data = EXCLUDED.mapping_data,
|
||||
saved_at = EXCLUDED.saved_at',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'format' => $format->value,
|
||||
'mapping_data' => json_encode($serialized, JSON_THROW_ON_ERROR),
|
||||
'saved_at' => (new DateTimeImmutable())->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT mapping_data FROM saved_teacher_column_mappings WHERE tenant_id = :tenant_id AND format = :format',
|
||||
[
|
||||
'tenant_id' => (string) $tenantId,
|
||||
'format' => $format->value,
|
||||
],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var string $json */
|
||||
$json = $row['mapping_data'];
|
||||
|
||||
/** @var array<string, string> $data */
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$mapping = [];
|
||||
foreach ($data as $column => $fieldValue) {
|
||||
$field = TeacherImportField::tryFrom($fieldValue);
|
||||
if ($field !== null) {
|
||||
$mapping[$column] = $field;
|
||||
}
|
||||
}
|
||||
|
||||
return $mapping !== [] ? $mapping : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\Doctrine;
|
||||
|
||||
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\ImportRow;
|
||||
use App\Administration\Domain\Model\Import\ImportRowError;
|
||||
use App\Administration\Domain\Model\Import\ImportStatus;
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportBatch;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
|
||||
use const JSON_THROW_ON_ERROR;
|
||||
|
||||
use Override;
|
||||
|
||||
final readonly class DoctrineTeacherImportBatchRepository implements TeacherImportBatchRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(TeacherImportBatch $batch): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO teacher_import_batches
|
||||
(id, tenant_id, original_filename, total_rows, detected_columns, detected_format,
|
||||
status, mapping_data, imported_count, error_count, rows_data, created_at, completed_at)
|
||||
VALUES
|
||||
(:id, :tenant_id, :original_filename, :total_rows, :detected_columns, :detected_format,
|
||||
:status, :mapping_data, :imported_count, :error_count, :rows_data, :created_at, :completed_at)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
total_rows = EXCLUDED.total_rows,
|
||||
status = EXCLUDED.status,
|
||||
mapping_data = EXCLUDED.mapping_data,
|
||||
imported_count = EXCLUDED.imported_count,
|
||||
error_count = EXCLUDED.error_count,
|
||||
rows_data = EXCLUDED.rows_data,
|
||||
completed_at = EXCLUDED.completed_at',
|
||||
[
|
||||
'id' => (string) $batch->id,
|
||||
'tenant_id' => (string) $batch->tenantId,
|
||||
'original_filename' => $batch->originalFilename,
|
||||
'total_rows' => $batch->totalRows,
|
||||
'detected_columns' => json_encode($batch->detectedColumns, JSON_THROW_ON_ERROR),
|
||||
'detected_format' => $batch->detectedFormat?->value,
|
||||
'status' => $batch->status->value,
|
||||
'mapping_data' => $batch->mapping !== null
|
||||
? json_encode($this->serializeMapping($batch->mapping), JSON_THROW_ON_ERROR)
|
||||
: null,
|
||||
'imported_count' => $batch->importedCount,
|
||||
'error_count' => $batch->errorCount,
|
||||
'rows_data' => json_encode($this->serializeRows($batch->lignes()), JSON_THROW_ON_ERROR),
|
||||
'created_at' => $batch->createdAt->format(DateTimeImmutable::ATOM),
|
||||
'completed_at' => $batch->completedAt?->format(DateTimeImmutable::ATOM),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ImportBatchId $id): TeacherImportBatch
|
||||
{
|
||||
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ImportBatchId $id): ?TeacherImportBatch
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM teacher_import_batches WHERE id = :id',
|
||||
['id' => (string) $id],
|
||||
);
|
||||
|
||||
if ($row === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->hydrate($row);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenant(TenantId $tenantId): array
|
||||
{
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT * FROM teacher_import_batches WHERE tenant_id = :tenant_id ORDER BY created_at DESC',
|
||||
['tenant_id' => (string) $tenantId],
|
||||
);
|
||||
|
||||
return array_map(fn ($row) => $this->hydrate($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function hydrate(array $row): TeacherImportBatch
|
||||
{
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $tenantId */
|
||||
$tenantId = $row['tenant_id'];
|
||||
/** @var string $originalFilename */
|
||||
$originalFilename = $row['original_filename'];
|
||||
/** @var string|int $totalRowsRaw */
|
||||
$totalRowsRaw = $row['total_rows'];
|
||||
$totalRows = (int) $totalRowsRaw;
|
||||
/** @var string $detectedColumnsJson */
|
||||
$detectedColumnsJson = $row['detected_columns'];
|
||||
/** @var string|null $detectedFormat */
|
||||
$detectedFormat = $row['detected_format'];
|
||||
/** @var string $status */
|
||||
$status = $row['status'];
|
||||
/** @var string|null $mappingJson */
|
||||
$mappingJson = $row['mapping_data'];
|
||||
/** @var string|int $importedCountRaw */
|
||||
$importedCountRaw = $row['imported_count'];
|
||||
$importedCount = (int) $importedCountRaw;
|
||||
/** @var string|int $errorCountRaw */
|
||||
$errorCountRaw = $row['error_count'];
|
||||
$errorCount = (int) $errorCountRaw;
|
||||
/** @var string $rowsJson */
|
||||
$rowsJson = $row['rows_data'];
|
||||
/** @var string $createdAt */
|
||||
$createdAt = $row['created_at'];
|
||||
/** @var string|null $completedAt */
|
||||
$completedAt = $row['completed_at'];
|
||||
|
||||
/** @var list<string> $detectedColumns */
|
||||
$detectedColumns = json_decode($detectedColumnsJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$mapping = $mappingJson !== null ? $this->hydrateMapping($mappingJson) : null;
|
||||
|
||||
$batch = TeacherImportBatch::reconstitute(
|
||||
id: ImportBatchId::fromString($id),
|
||||
tenantId: TenantId::fromString($tenantId),
|
||||
originalFilename: $originalFilename,
|
||||
totalRows: $totalRows,
|
||||
detectedColumns: $detectedColumns,
|
||||
detectedFormat: $detectedFormat !== null ? KnownImportFormat::from($detectedFormat) : null,
|
||||
status: ImportStatus::from($status),
|
||||
mapping: $mapping,
|
||||
importedCount: $importedCount,
|
||||
errorCount: $errorCount,
|
||||
createdAt: new DateTimeImmutable($createdAt),
|
||||
completedAt: $completedAt !== null ? new DateTimeImmutable($completedAt) : null,
|
||||
);
|
||||
|
||||
$rows = $this->hydrateRows($rowsJson);
|
||||
$batch->enregistrerLignes($rows);
|
||||
|
||||
return $batch;
|
||||
}
|
||||
|
||||
private function hydrateMapping(string $json): TeacherColumnMapping
|
||||
{
|
||||
/** @var array{mapping: array<string, string>, format: string} $data */
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
/** @var array<string, TeacherImportField> $mapping */
|
||||
$mapping = [];
|
||||
foreach ($data['mapping'] as $column => $fieldValue) {
|
||||
$mapping[$column] = TeacherImportField::from($fieldValue);
|
||||
}
|
||||
|
||||
return TeacherColumnMapping::creer($mapping, KnownImportFormat::from($data['format']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<ImportRow>
|
||||
*/
|
||||
private function hydrateRows(string $json): array
|
||||
{
|
||||
/** @var list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}> $data */
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return array_map(
|
||||
static fn (array $rowData) => new ImportRow(
|
||||
lineNumber: $rowData['lineNumber'],
|
||||
rawData: $rowData['rawData'],
|
||||
mappedData: $rowData['mappedData'],
|
||||
errors: array_map(
|
||||
static fn (array $err) => new ImportRowError($err['column'], $err['message']),
|
||||
$rowData['errors'],
|
||||
),
|
||||
),
|
||||
$data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{mapping: array<string, string>, format: string}
|
||||
*/
|
||||
private function serializeMapping(TeacherColumnMapping $mapping): array
|
||||
{
|
||||
$serialized = [];
|
||||
foreach ($mapping->mapping as $column => $field) {
|
||||
$serialized[$column] = $field->value;
|
||||
}
|
||||
|
||||
return [
|
||||
'mapping' => $serialized,
|
||||
'format' => $mapping->format->value,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<ImportRow> $rows
|
||||
*
|
||||
* @return list<array{lineNumber: int, rawData: array<string, string>, mappedData: array<string, string>, errors: list<array{column: string, message: string}>}>
|
||||
*/
|
||||
private function serializeRows(array $rows): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (ImportRow $row) => [
|
||||
'lineNumber' => $row->lineNumber,
|
||||
'rawData' => $row->rawData,
|
||||
'mappedData' => $row->mappedData,
|
||||
'errors' => array_map(
|
||||
static fn (ImportRowError $error) => [
|
||||
'column' => $error->column,
|
||||
'message' => $error->message,
|
||||
],
|
||||
$row->errors,
|
||||
),
|
||||
],
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Model\Import\KnownImportFormat;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportField;
|
||||
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemorySavedTeacherColumnMappingRepository implements SavedTeacherColumnMappingRepository
|
||||
{
|
||||
/** @var array<string, array<string, TeacherImportField>> */
|
||||
private array $store = [];
|
||||
|
||||
#[Override]
|
||||
public function save(TenantId $tenantId, KnownImportFormat $format, array $mapping): void
|
||||
{
|
||||
$this->store[$this->key($tenantId, $format)] = $mapping;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenantAndFormat(TenantId $tenantId, KnownImportFormat $format): ?array
|
||||
{
|
||||
return $this->store[$this->key($tenantId, $format)] ?? null;
|
||||
}
|
||||
|
||||
private function key(TenantId $tenantId, KnownImportFormat $format): string
|
||||
{
|
||||
return (string) $tenantId . ':' . $format->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Administration\Infrastructure\Persistence\InMemory;
|
||||
|
||||
use App\Administration\Domain\Exception\ImportBatchNotFoundException;
|
||||
use App\Administration\Domain\Model\Import\ImportBatchId;
|
||||
use App\Administration\Domain\Model\Import\TeacherImportBatch;
|
||||
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
final class InMemoryTeacherImportBatchRepository implements TeacherImportBatchRepository
|
||||
{
|
||||
/** @var array<string, TeacherImportBatch> */
|
||||
private array $byId = [];
|
||||
|
||||
#[Override]
|
||||
public function save(TeacherImportBatch $batch): void
|
||||
{
|
||||
$this->byId[$batch->id->__toString()] = $batch;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function get(ImportBatchId $id): TeacherImportBatch
|
||||
{
|
||||
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ImportBatchId $id): ?TeacherImportBatch
|
||||
{
|
||||
return $this->byId[$id->__toString()] ?? null;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findByTenant(TenantId $tenantId): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
$this->byId,
|
||||
static fn (TeacherImportBatch $batch): bool => $batch->tenantId->equals($tenantId),
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user