feat: Permettre l'import d'élèves via fichier CSV ou XLSX
L'import manuel élève par élève est fastidieux pour les établissements qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes (upload → mapping → preview → confirmation) permet de traiter un fichier complet en une seule opération, avec détection automatique du format (Pronote, École Directe) et validation avant import. L'import est traité de manière asynchrone via Messenger pour ne pas bloquer l'interface, avec suivi de progression en temps réel et réutilisation des mappings entre imports successifs.
This commit is contained in:
@@ -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\ColumnMapping;
|
||||
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\StudentImportBatch;
|
||||
use App\Administration\Domain\Model\Import\StudentImportField;
|
||||
use App\Administration\Domain\Repository\ImportBatchRepository;
|
||||
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 DoctrineImportBatchRepository implements ImportBatchRepository
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function save(StudentImportBatch $batch): void
|
||||
{
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO student_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): StudentImportBatch
|
||||
{
|
||||
return $this->findById($id) ?? throw ImportBatchNotFoundException::withId($id);
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function findById(ImportBatchId $id): ?StudentImportBatch
|
||||
{
|
||||
$row = $this->connection->fetchAssociative(
|
||||
'SELECT * FROM student_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 student_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): StudentImportBatch
|
||||
{
|
||||
/** @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 = StudentImportBatch::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): ColumnMapping
|
||||
{
|
||||
/** @var array{mapping: array<string, string>, format: string} $data */
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
/** @var array<string, StudentImportField> $mapping */
|
||||
$mapping = [];
|
||||
foreach ($data['mapping'] as $column => $fieldValue) {
|
||||
$mapping[$column] = StudentImportField::from($fieldValue);
|
||||
}
|
||||
|
||||
return ColumnMapping::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(ColumnMapping $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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user