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

@@ -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;
}
}