pour prod

This commit is contained in:
Loic Masi
2026-08-07 16:13:43 +02:00
commit 5fbf76868f
157 changed files with 24085 additions and 0 deletions

View File

@@ -0,0 +1,644 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\PlanningModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Models\VacationModel;
use DateTimeImmutable;
use DomainException;
use PDO;
use Throwable;
final class PlanningController
{
private PlanningModel $planningModel;
private AgentModel $agentModel;
private StructureModel $structureModel;
private MotifModel $motifModel;
private QuotaModel $quotaModel;
private VacationModel $vacationModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->planningModel = new PlanningModel($pdo);
$this->agentModel = new AgentModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->motifModel = new MotifModel($pdo);
$this->quotaModel = new QuotaModel($pdo);
$this->vacationModel = new VacationModel($pdo);
$this->access = new Access($pdo);
}
public function show(): void
{
$this->access->requireDraftEditor();
$structureId = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT);
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
if (!$structureId || !$agentId || $week === null) {
JsonResponse::send(400, ['error' => 'Paramètres invalides.']);
}
$this->access->requireStructureEdit((int) $structureId);
$structure = $this->structureModel->findActive((int) $structureId);
if ($structure === null) {
JsonResponse::send(404, ['error' => 'Lieu daffectation introuvable.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
[$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$planning = $this->planningModel->findByContext((int) $structureId, (int) $agentId, $year, $weekNumber);
JsonResponse::send(200, [
'planning' => $planning,
'entries' => $planning ? $this->planningModel->entries((int) $planning['id_planning']) : [],
'other_entries' => $this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId),
'current_structure' => $structure,
'agent' => $agent,
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate),
'calendar_days' => $this->vacationModel->typesForIsoWeek($year, $weekNumber),
]);
}
public function save(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$weekValue = trim((string) ($payload['week'] ?? ''));
$rawEntries = $payload['entries'] ?? [];
$week = $this->parseWeek($weekValue);
if (!$structureId || !$agentId || $week === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation, agent ou semaine invalide.']);
}
$this->access->requireStructureEdit((int) $structureId);
if (!is_array($rawEntries)) {
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide ou inactif.']);
}
[$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIds());
$this->assertNoInternalOverlap($entries);
$this->assertNoOtherLocationOverlap(
$entries,
$this->planningModel->otherLocationEntries((int) $agentId, $year, $weekNumber, (int) $structureId)
);
try {
$planningId = $this->planningModel->saveDraft(
(int) $structureId,
(int) $agentId,
$year,
$weekNumber,
$entries
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Erreur lors de lenregistrement du planning.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
JsonResponse::send(200, [
'success' => true,
'planning_id' => $planningId,
'empty' => count($entries) === 0,
'message' => count($entries) === 0
? 'Toutes les affectations ont été supprimées. Le planning vide a été enregistré.'
: 'Brouillon enregistré avec succès.',
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStartDate),
]);
}
public function updateAgentEntry(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT);
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$motifId = filter_var($payload['motif_id'] ?? null, FILTER_VALIDATE_INT);
$date = trim((string) ($payload['date'] ?? ''));
$start = trim((string) ($payload['start'] ?? ''));
$end = trim((string) ($payload['end'] ?? ''));
if (!$entryId || !$structureId || !$motifId) {
JsonResponse::send(422, ['error' => 'Créneau, lieu ou motif invalide.']);
}
$this->access->requireEntryEdit((int) $entryId);
$this->access->requireStructureEdit((int) $structureId);
$entry = $this->planningModel->findEntryDetailed((int) $entryId);
if ($entry === null) {
JsonResponse::send(404, ['error' => 'Créneau introuvable.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu daffectation invalide ou inactif.']);
}
if (!in_array((int) $motifId, $this->motifModel->activeIds(), true)) {
JsonResponse::send(422, ['error' => 'Motif invalide ou inactif.']);
}
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
if (!$validDate || (int) $dateObj->format('N') > 5 || !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'La date ou la plage horaire est invalide. Le jour doit être compris entre le lundi et le vendredi et les horaires saisis par quarts dheure.']);
}
try {
$result = $this->planningModel->updateEntryFromAgentView(
(int) $entryId,
(int) $structureId,
(int) $motifId,
$date,
$start . ':00',
$end . ':00'
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de modifier le créneau.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$reopened = count($result['reopened_planning_ids']) > 0;
JsonResponse::send(200, [
'success' => true,
'message' => $reopened
? 'Créneau modifié. Le ou les plannings validés concernés ont été repassés en brouillon et devront être validés à nouveau.'
: 'Créneau modifié avec succès.',
'requires_revalidation' => $reopened,
'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $date),
]);
}
public function deleteAgentEntry(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT);
if (!$entryId) {
JsonResponse::send(422, ['error' => 'Créneau invalide.']);
}
$this->access->requireEntryEdit((int) $entryId);
try {
$result = $this->planningModel->deleteEntryFromAgentView((int) $entryId);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de supprimer le créneau.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$message = $result['remaining_entries'] === 0
? 'Le dernier créneau a été supprimé. Le planning est maintenant vide.'
: 'Créneau supprimé avec succès.';
if ($result['reopened']) {
$message .= ' Le planning validé concerné a été repassé en brouillon.';
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'planning_empty' => $result['remaining_entries'] === 0,
'requires_revalidation' => $result['reopened'],
'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], (string) $result['date']),
]);
}
public function moveOrDuplicateEntry(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$entryId = filter_var($payload['entry_id'] ?? null, FILTER_VALIDATE_INT);
$targetDate = trim((string) ($payload['target_date'] ?? ''));
$action = strtolower(trim((string) ($payload['action'] ?? 'move')));
if (!$entryId || !in_array($action, ['move', 'duplicate'], true)) {
JsonResponse::send(422, ['error' => 'Créneau ou action invalide.']);
}
$this->access->requireEntryEdit((int) $entryId);
$entry = $this->planningModel->findEntryDetailed((int) $entryId);
if ($entry === null) {
JsonResponse::send(404, ['error' => 'Créneau introuvable.']);
}
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $targetDate);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $targetDate;
if (!$validDate || (int) $dateObj->format('N') > 5) {
JsonResponse::send(422, ['error' => 'Le jour cible doit être un jour ouvré du lundi au vendredi.']);
}
try {
$result = $this->planningModel->moveOrDuplicateEntry(
(int) $entryId,
$targetDate,
$action === 'duplicate'
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => $action === 'duplicate'
? 'Impossible de dupliquer le créneau.'
: 'Impossible de déplacer le créneau.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$reopened = count($result['reopened_planning_ids']) > 0;
$message = match ($result['action']) {
'duplicate' => 'Créneau dupliqué avec succès.',
'move' => 'Créneau déplacé avec succès.',
default => 'Le créneau se trouve déjà sur ce jour.',
};
if ($reopened) {
$message .= ' Le planning concerné a été repassé en brouillon et devra être validé à nouveau.';
}
JsonResponse::send(200, [
'success' => true,
'action' => $result['action'],
'message' => $message,
'requires_revalidation' => $reopened,
'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $targetDate),
]);
}
public function copyAgentWeek(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$sourceWeekValue = trim((string) ($payload['source_week'] ?? ''));
$targetWeekValue = trim((string) ($payload['target_week'] ?? ''));
$mode = strtolower(trim((string) ($payload['mode'] ?? 'merge')));
$sourceWeek = $this->parseWeek($sourceWeekValue);
$targetWeek = $this->parseWeek($targetWeekValue);
if (!$agentId || $sourceWeek === null || $targetWeek === null || !in_array($mode, ['merge', 'replace'], true)) {
JsonResponse::send(422, ['error' => 'Agent, semaines ou mode de duplication invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
}
try {
$result = $this->planningModel->copyAgentWeek(
(int) $agentId,
$sourceWeek[0],
$sourceWeek[1],
$targetWeek[0],
$targetWeek[1],
$mode
);
} catch (DomainException $e) {
JsonResponse::send(409, ['error' => $e->getMessage()]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de dupliquer la semaine.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
$message = sprintf(
'%d créneau(x) ont été copiés vers %s.',
(int) $result['copied_entries'],
$targetWeekValue
);
if ($result['reopened_planning_ids']) {
$message .= ' Les plannings validés modifiés ont été repassés en brouillon.';
}
JsonResponse::send(200, [
'success' => true,
'message' => $message,
'requires_revalidation' => count($result['reopened_planning_ids']) > 0,
'quota' => $this->quotaModel->forDateForApi(
(int) $agentId,
(new DateTimeImmutable())->setISODate($targetWeek[0], $targetWeek[1], 1)->format('Y-m-d')
),
]);
}
public function pendingValidation(): void
{
$this->access->requireService();
$plannings = $this->planningModel->pendingValidation();
$totalMinutes = array_sum(array_map(
static fn(array $planning): int => (int) $planning['total_minutes'],
$plannings
));
$countedMinutes = array_sum(array_map(
static fn(array $planning): int => (int) $planning['minutes_decomptees'],
$plannings
));
JsonResponse::send(200, [
'plannings' => $plannings,
'summary' => [
'count' => count($plannings),
'total_minutes' => $totalMinutes,
'counted_minutes' => $countedMinutes,
],
]);
}
public function validate(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT);
$forceQuota = filter_var($payload['force_quota'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (!$planningId) {
JsonResponse::send(422, ['error' => 'Planning invalide.']);
}
$planning = $this->planningModel->findDetailed((int) $planningId);
if ($planning === null) {
JsonResponse::send(404, ['error' => 'Planning introuvable.']);
}
$controls = [];
if ($this->planningModel->countEntries((int) $planningId) === 0) {
$this->addControl($controls, 'ERREUR', 'AUCUN_CRENEAU', 'Le planning ne contient aucune affectation.');
}
foreach ($this->planningModel->entriesOutsideWeek((int) $planningId, $planning['date_debut'], $planning['date_fin']) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CRENEAU_HORS_SEMAINE',
sprintf('Laffectation #%d du %s est en dehors de la semaine sélectionnée.', $row['id_creneau'], $row['date_jour'])
);
}
foreach ($this->planningModel->internalOverlaps((int) $planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_INTERNE',
sprintf('Les affectations #%d et #%d se chevauchent le %s.', $row['c1'], $row['c2'], $row['date_jour'])
);
}
foreach ($this->planningModel->validatedExternalOverlaps((int) $planningId) as $row) {
$this->addControl(
$controls,
'ERREUR',
'CHEVAUCHEMENT_AUTRE_STRUCTURE',
sprintf(
'Laffectation #%d chevauche laffectation #%d déjà validée sur le lieu « %s » le %s.',
$row['c1'],
$row['c2'],
$row['structure_nom'],
$row['date_jour']
)
);
}
$quota = $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']);
if ((int) $quota['minutes_restantes'] < 0) {
$this->addControl(
$controls,
'ALERTE',
'QUOTA_DEPASSE',
sprintf(
'Le quota annuel est dépassé de %s. Quota cible : %s ; affecté : %s.',
$this->quotaModel->formatMinutes(abs((int) $quota['minutes_restantes']))['libelle'],
$quota['quota_cible']['libelle'],
$quota['affectees']['libelle']
)
);
}
$errors = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ERREUR'));
$alerts = array_values(array_filter($controls, static fn(array $row): bool => $row['niveau'] === 'ALERTE'));
if ($errors) {
JsonResponse::send(422, [
'success' => false,
'status' => 'errors',
'controls' => $controls,
'quota' => $quota,
'error' => 'Le planning contient des erreurs bloquantes.',
]);
}
if ($alerts && !$forceQuota) {
JsonResponse::send(409, [
'success' => false,
'status' => 'warning',
'controls' => $controls,
'quota' => $quota,
'message' => 'Le planning dépasse le quota annuel. Une confirmation est nécessaire.',
]);
}
$this->planningModel->markValidated((int) $planningId);
$this->addControl($controls, 'INFO', 'OK', 'Le planning a été validé.');
JsonResponse::send(200, [
'success' => true,
'status' => 'validated',
'controls' => $controls,
'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']),
'message' => 'Planning validé avec succès.',
]);
}
public function reopen(): void
{
$this->access->requireDraftEditor();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$planningId = filter_var($payload['planning_id'] ?? null, FILTER_VALIDATE_INT);
if (!$planningId) {
JsonResponse::send(422, ['error' => 'Planning invalide.']);
}
$this->access->requirePlanningEdit((int) $planningId);
$planning = $this->planningModel->findDetailed((int) $planningId);
if ($planning === null) {
JsonResponse::send(404, ['error' => 'Planning introuvable.']);
}
if ($planning['statut'] !== 'VALIDE') {
JsonResponse::send(409, ['error' => 'Ce planning est déjà modifiable.']);
}
if (!$this->planningModel->reopen((int) $planningId)) {
JsonResponse::send(409, ['error' => 'Le planning a été modifié entre-temps. Rechargez la page.']);
}
JsonResponse::send(200, [
'success' => true,
'status' => 'BROUILLON',
'message' => 'Le planning est repassé en brouillon. Vous pouvez maintenant corriger les affectations puis le valider à nouveau.',
'quota' => $this->quotaModel->forDateForApi((int) $planning['id_agent'], (string) $planning['date_debut']),
]);
}
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$year = (int) $matches[1];
$week = (int) $matches[2];
if ($week < 1 || $week > 53) {
return null;
}
return [$year, $week];
}
private function normalizeEntries(array $entries, int $year, int $weekNumber, array $validMotifs): array
{
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->setTime(0, 0);
$weekEnd = $weekStart->modify('+6 days');
$normalized = [];
foreach ($entries as $index => $entry) {
$date = (string) ($entry['date'] ?? '');
$motifId = filter_var($entry['motif_id'] ?? null, FILTER_VALIDATE_INT);
$start = (string) ($entry['start'] ?? '');
$end = (string) ($entry['end'] ?? '');
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
if (!$validDate || !$motifId || !in_array((int) $motifId, $validMotifs, true)
|| !$quarterHour($start) || !$quarterHour($end) || $end <= $start) {
JsonResponse::send(422, ['error' => 'Affectation invalide à la ligne ' . ($index + 1) . '.']);
}
if ($dateObj < $weekStart || $dateObj > $weekEnd) {
JsonResponse::send(422, ['error' => 'Une affectation se trouve en dehors de la semaine sélectionnée.']);
}
$normalized[] = [
'date' => $date,
'motif_id' => (int) $motifId,
'start' => $start . ':00',
'end' => $end . ':00',
];
}
return $normalized;
}
private function assertNoInternalOverlap(array $entries): void
{
for ($i = 0, $count = count($entries); $i < $count; $i++) {
for ($j = $i + 1; $j < $count; $j++) {
$first = $entries[$i];
$second = $entries[$j];
if ($first['date'] === $second['date'] && $first['start'] < $second['end'] && $first['end'] > $second['start']) {
JsonResponse::send(409, [
'error' => sprintf(
'Deux affectations saisies se chevauchent le %s (%s-%s et %s-%s).',
$first['date'],
substr($first['start'], 0, 5),
substr($first['end'], 0, 5),
substr($second['start'], 0, 5),
substr($second['end'], 0, 5)
),
'code' => 'CHEVAUCHEMENT_SAISIE',
]);
}
}
}
}
private function assertNoOtherLocationOverlap(array $entries, array $existingEntries): void
{
foreach ($entries as $entry) {
foreach ($existingEntries as $existing) {
$existingStart = $existing['heure_debut'] . ':00';
$existingEnd = $existing['heure_fin'] . ':00';
if ($entry['date'] === $existing['date_jour'] && $entry['start'] < $existingEnd && $entry['end'] > $existingStart) {
JsonResponse::send(409, [
'error' => sprintf(
'Double affectation impossible : lagent est déjà planifié sur le lieu « %s » le %s de %s à %s (%s).',
$existing['structure_nom'],
$existing['date_jour'],
$existing['heure_debut'],
$existing['heure_fin'],
strtolower((string) $existing['statut'])
),
'code' => 'CHEVAUCHEMENT_AUTRE_STRUCTURE',
'conflict' => [
'structure' => $existing['structure_nom'],
'date' => $existing['date_jour'],
'start' => $existing['heure_debut'],
'end' => $existing['heure_fin'],
'status' => $existing['statut'],
],
]);
}
}
}
}
private function addControl(array &$controls, string $level, string $code, string $message): void
{
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
}
}