Files
pta/app/Controllers/PlanningController.php
PTA 30cb50031d Droit à l'enveloppe de formation par agent, et motif « Formation » filtré à la saisie
Nouvelle colonne `agent.droit_enveloppe_formation`, cochée par défaut, saisie à
la création de l'agent et modifiable depuis sa fiche. Un agent sans ce droit :

- ne voit plus le motif « Formation » dans le sélecteur de motif de l'éditeur de
  planning ni dans le modal de la vue individuelle ;
- se voit refuser toute affectation de ce motif par le serveur, avec un message
  qui nomme le motif et l'agent (save_planning.php et update_agent_entry.php) ;
- a une enveloppe de formation de 0 min au PTA annuel, et une ALERTE
  FORMATION_SANS_DROIT si des heures de formation subsistent dans son planning.

À ne pas confondre avec `formation_repartition_annuelle`, qui existait déjà :
celle-ci dit que la formation est déjà comptée dans la répartition annuelle —
l'agent y a droit, l'enveloppe complémentaire n'est simplement pas ajoutée. Les
deux règles sont désormais rassemblées dans AgentModel::trainingEnvelopeMinutes(),
qui remplace les deux calculs « 840 ou 0 » jusque-là dupliqués.

Le motif interdit reste affiché lorsqu'il est déjà celui de la plage en cours
d'édition : le retirer de la liste changerait silencieusement le motif d'une
saisie existante. C'est le contrôle de saisie, puis le serveur, qui demandent
alors de corriger la plage.

La migration est nommée v7_9b et non v7_10 : les fichiers sont joués dans
l'ordre alphabétique, où « v7_10 » passerait avant « v7_8 ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:30:43 +02:00

697 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\MotifCategories;
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 d’affectation 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 d’affectation, agent ou semaine invalide.']);
}
$this->access->requireStructureEdit((int) $structureId);
if (!is_array($rawEntries)) {
JsonResponse::send(422, ['error' => 'La liste des affectations est invalide.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
}
[$year, $weekNumber] = $week;
$weekStartDate = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
// Les motifs autorisés dépendent de l'agent : « Formation » n'est pas
// affectable à un agent sans droit à l'enveloppe de formation.
$this->assertMotifsAllowedForAgent($rawEntries, $agent);
$entries = $this->normalizeEntries($rawEntries, $year, $weekNumber, $this->motifModel->activeIdsForAgent($agent));
$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 l’enregistrement 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 d’affectation invalide ou inactif.']);
}
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
if ($entryAgent === null) {
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
}
$this->assertMotifsAllowedForAgent([['motif_id' => $motifId]], $entryAgent);
if (!in_array((int) $motifId, $this->motifModel->activeIdsForAgent($entryAgent), 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 d’heure.']);
}
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('L’affectation #%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(
'L’affectation #%d chevauche l’affectation #%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];
}
/**
* Refuse les affectations portant un motif fermé à l'agent, avec un message
* qui nomme le motif : normalizeEntries() rejetterait bien la ligne, mais
* sans dire pourquoi. L'interface masque déjà ces motifs — reste les
* plannings saisis avant le retrait du droit, et les appels directs.
*
* @param array<int, mixed> $rawEntries
*/
private function assertMotifsAllowedForAgent(array $rawEntries, array $agent): void
{
if (AgentModel::hasTrainingRight($agent)) {
return;
}
$restricted = [];
foreach ($this->motifModel->allActive() as $motif) {
if (MotifCategories::requiresTrainingRight((string) $motif['code'])) {
$restricted[(int) $motif['id_motif']] = (string) $motif['libelle'];
}
}
if ($restricted === []) {
return;
}
foreach ($rawEntries as $entry) {
if (!is_array($entry)) {
continue;
}
$motifId = (int) filter_var($entry['motif_id'] ?? 0, FILTER_VALIDATE_INT);
if (isset($restricted[$motifId])) {
JsonResponse::send(422, [
'error' => sprintf(
'Le motif « %s » ne peut pas être affecté à %s %s : cet agent n’a pas droit à l’enveloppe de formation. Retirez ou remplacez ces plages avant d’enregistrer.',
$restricted[$motifId],
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? '')
),
]);
}
}
}
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 : l’agent 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];
}
}