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:
@@ -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