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.
407 lines
14 KiB
PHP
407 lines
14 KiB
PHP
<?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;
|
|
}
|
|
}
|