feat: Permettre à l'enseignant de saisir les notes dans une grille inline
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

L'enseignant avait besoin d'un moyen rapide de saisir les notes après
une évaluation. La grille inline permet de compléter 30 élèves en moins
de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab),
la validation temps réel, l'auto-save debounced (500ms) et les
raccourcis /abs et /disp pour marquer absents/dispensés.

Les notes restent en brouillon jusqu'à publication explicite (avec
confirmation modale). Une fois publiées, les élèves les voient
immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy).
Le mode offline stocke les notes en IndexedDB et synchronise
automatiquement au retour de la connexion.

Chaque modification est auditée dans grade_events via un event
subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
This commit is contained in:
2026-03-29 09:55:45 +02:00
parent 98be1951bf
commit b70d5ec2ad
45 changed files with 3902 additions and 11 deletions

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class NoteModifiee implements DomainEvent
{
public function __construct(
public GradeId $gradeId,
public string $evaluationId,
public ?float $oldValue,
public ?float $newValue,
public string $oldStatus,
public string $newStatus,
public string $modifiedBy,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->gradeId->value;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class NoteSaisie implements DomainEvent
{
public function __construct(
public GradeId $gradeId,
public string $evaluationId,
public string $studentId,
public ?float $value,
public string $status,
public string $createdBy,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->gradeId->value;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Event;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class NotesPubliees implements DomainEvent
{
public function __construct(
public EvaluationId $evaluationId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->evaluationId->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class AucuneNoteSaisieException extends DomainException
{
public static function pourEvaluation(EvaluationId $id): self
{
return new self(sprintf(
'Impossible de publier l\'évaluation "%s" : aucune note n\'a été saisie.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Grade\GradeId;
use DomainException;
use function sprintf;
final class GradeNotFoundException extends DomainException
{
public static function withId(GradeId $id): self
{
return new self(sprintf(
'La note avec l\'ID "%s" n\'a pas été trouvée.',
$id,
));
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
final class NoteRequiseException extends DomainException
{
public static function pourStatutNote(): self
{
return new self(
'Une valeur de note est requise pour le statut "graded".',
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use DomainException;
use function sprintf;
final class NotesDejaPublieesException extends DomainException
{
public static function pourEvaluation(EvaluationId $id): self
{
return new self(sprintf(
'Les notes de l\'évaluation "%s" sont déjà publiées.',
$id,
));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Exception;
use DomainException;
use function sprintf;
final class ValeurNoteInvalideException extends DomainException
{
public static function valeurNegative(float $value): self
{
return new self(sprintf(
'La valeur de la note ne peut pas être négative, %s donné.',
$value,
));
}
public static function depasseBareme(float $value, int $maxValue): self
{
return new self(sprintf(
'La valeur de la note (%s) dépasse le barème maximum (%d).',
$value,
$maxValue,
));
}
}

View File

@@ -10,8 +10,10 @@ use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\EvaluationCreee;
use App\Scolarite\Domain\Event\EvaluationModifiee;
use App\Scolarite\Domain\Event\EvaluationSupprimee;
use App\Scolarite\Domain\Event\NotesPubliees;
use App\Scolarite\Domain\Exception\BaremeNonModifiableException;
use App\Scolarite\Domain\Exception\EvaluationDejaSupprimeeException;
use App\Scolarite\Domain\Exception\NotesDejaPublieesException;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
@@ -19,6 +21,7 @@ use DateTimeImmutable;
final class Evaluation extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
public private(set) ?DateTimeImmutable $gradesPublishedAt = null;
private function __construct(
public private(set) EvaluationId $id,
@@ -113,6 +116,30 @@ final class Evaluation extends AggregateRoot
));
}
public function publierNotes(DateTimeImmutable $now): void
{
if ($this->status === EvaluationStatus::DELETED) {
throw EvaluationDejaSupprimeeException::withId($this->id);
}
if ($this->gradesPublishedAt !== null) {
throw NotesDejaPublieesException::pourEvaluation($this->id);
}
$this->gradesPublishedAt = $now;
$this->updatedAt = $now;
$this->recordEvent(new NotesPubliees(
evaluationId: $this->id,
occurredOn: $now,
));
}
public function notesPubliees(): bool
{
return $this->gradesPublishedAt !== null;
}
public function supprimer(DateTimeImmutable $now): void
{
if ($this->status === EvaluationStatus::DELETED) {
@@ -145,6 +172,7 @@ final class Evaluation extends AggregateRoot
EvaluationStatus $status,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
?DateTimeImmutable $gradesPublishedAt = null,
): self {
$evaluation = new self(
id: $id,
@@ -162,6 +190,7 @@ final class Evaluation extends AggregateRoot
);
$evaluation->updatedAt = $updatedAt;
$evaluation->gradesPublishedAt = $gradesPublishedAt;
return $evaluation;
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Domain\Event\NoteModifiee;
use App\Scolarite\Domain\Event\NoteSaisie;
use App\Scolarite\Domain\Exception\NoteRequiseException;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Evaluation\GradeScale;
use App\Shared\Domain\AggregateRoot;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
final class Grade extends AggregateRoot
{
public private(set) DateTimeImmutable $updatedAt;
private function __construct(
public private(set) GradeId $id,
public private(set) TenantId $tenantId,
public private(set) EvaluationId $evaluationId,
public private(set) UserId $studentId,
public private(set) ?GradeValue $value,
public private(set) GradeStatus $status,
public private(set) UserId $createdBy,
public private(set) DateTimeImmutable $createdAt,
) {
$this->updatedAt = $createdAt;
}
public static function saisir(
TenantId $tenantId,
EvaluationId $evaluationId,
UserId $studentId,
?GradeValue $value,
GradeStatus $status,
GradeScale $gradeScale,
UserId $createdBy,
DateTimeImmutable $now,
): self {
self::validerCoherence($value, $status, $gradeScale);
$grade = new self(
id: GradeId::generate(),
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: $status,
createdBy: $createdBy,
createdAt: $now,
);
$grade->recordEvent(new NoteSaisie(
gradeId: $grade->id,
evaluationId: (string) $evaluationId,
studentId: (string) $studentId,
value: $value?->value,
status: $status->value,
createdBy: (string) $createdBy,
occurredOn: $now,
));
return $grade;
}
public function modifier(
?GradeValue $value,
GradeStatus $status,
GradeScale $gradeScale,
UserId $modifiedBy,
DateTimeImmutable $now,
): void {
self::validerCoherence($value, $status, $gradeScale);
$oldValue = $this->value?->value;
$oldStatus = $this->status->value;
$this->value = $value;
$this->status = $status;
$this->updatedAt = $now;
$this->recordEvent(new NoteModifiee(
gradeId: $this->id,
evaluationId: (string) $this->evaluationId,
oldValue: $oldValue,
newValue: $value?->value,
oldStatus: $oldStatus,
newStatus: $status->value,
modifiedBy: (string) $modifiedBy,
occurredOn: $now,
));
}
/**
* @internal Pour usage Infrastructure uniquement
*/
public static function reconstitute(
GradeId $id,
TenantId $tenantId,
EvaluationId $evaluationId,
UserId $studentId,
?GradeValue $value,
GradeStatus $status,
UserId $createdBy,
DateTimeImmutable $createdAt,
DateTimeImmutable $updatedAt,
): self {
$grade = new self(
id: $id,
tenantId: $tenantId,
evaluationId: $evaluationId,
studentId: $studentId,
value: $value,
status: $status,
createdBy: $createdBy,
createdAt: $createdAt,
);
$grade->updatedAt = $updatedAt;
return $grade;
}
private static function validerCoherence(
?GradeValue $value,
GradeStatus $status,
GradeScale $gradeScale,
): void {
if ($status === GradeStatus::GRADED && $value === null) {
throw NoteRequiseException::pourStatutNote();
}
if ($value !== null && $value->value > $gradeScale->maxValue) {
throw ValeurNoteInvalideException::depasseBareme($value->value, $gradeScale->maxValue);
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
use App\Shared\Domain\EntityId;
final readonly class GradeId extends EntityId
{
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
enum GradeStatus: string
{
case GRADED = 'graded';
case ABSENT = 'absent';
case DISPENSED = 'dispensed';
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Model\Grade;
use function abs;
use App\Scolarite\Domain\Exception\ValeurNoteInvalideException;
final readonly class GradeValue
{
public function __construct(
public float $value,
) {
if ($value < 0) {
throw ValeurNoteInvalideException::valeurNegative($value);
}
}
public function equals(self $other): bool
{
return abs($this->value - $other->value) < 0.001;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Policy;
use App\Scolarite\Domain\Model\Evaluation\Evaluation;
use App\Shared\Domain\Clock;
final readonly class VisibiliteNotesPolicy
{
private const int DELAI_PARENTS_HEURES = 24;
public function __construct(
private Clock $clock,
) {
}
public function visiblePourEleve(Evaluation $evaluation): bool
{
return $evaluation->notesPubliees();
}
public function visiblePourParent(Evaluation $evaluation): bool
{
if (!$evaluation->notesPubliees()) {
return false;
}
$delai = $evaluation->gradesPublishedAt?->modify('+' . self::DELAI_PARENTS_HEURES . ' hours');
return $delai !== null && $delai <= $this->clock->now();
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Domain\Repository;
use App\Scolarite\Domain\Exception\GradeNotFoundException;
use App\Scolarite\Domain\Model\Evaluation\EvaluationId;
use App\Scolarite\Domain\Model\Grade\Grade;
use App\Scolarite\Domain\Model\Grade\GradeId;
use App\Shared\Domain\Tenant\TenantId;
interface GradeRepository
{
public function save(Grade $grade): void;
/** @throws GradeNotFoundException */
public function get(GradeId $id, TenantId $tenantId): Grade;
public function findById(GradeId $id, TenantId $tenantId): ?Grade;
/** @return array<Grade> */
public function findByEvaluation(EvaluationId $evaluationId, TenantId $tenantId): array;
public function hasGradesForEvaluation(EvaluationId $evaluationId, TenantId $tenantId): bool;
}