1060 lines
45 KiB
PHP
1060 lines
45 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Controllers;
|
||
|
||
use App\Core\Access;
|
||
use App\Core\Csrf;
|
||
use App\Core\Journal;
|
||
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;
|
||
|
||
/**
|
||
* Saisie, contrôle et validation des plannings hebdomadaires.
|
||
*
|
||
* C'est le contrôleur central de l'application : un `planning` = un agent × une
|
||
* semaine ISO × un lieu, portant des `creneau_horaire` typés par un
|
||
* `motif_planning`.
|
||
*
|
||
* ATTENTION — logique de validation dupliquée : le schéma SQL définit des
|
||
* procédures `sp_controler_planning` / `sp_valider_planning` qui ne sont
|
||
* appelées par AUCUN code PHP. Les règles réellement appliquées sont celles de
|
||
* validate() ci-dessous. Modifier une règle métier impose de traiter le PHP ;
|
||
* le SQL correspondant est du patrimoine, pas la source de vérité.
|
||
*
|
||
* Forme commune des contrôles renvoyés au client, partout dans l'application :
|
||
* {niveau: ERREUR|ALERTE|INFO, code, message}
|
||
* — ERREUR bloque (HTTP 422) ;
|
||
* — ALERTE demande une confirmation explicite (HTTP 409, puis rappel avec
|
||
* force_quota: true) ;
|
||
* — INFO est purement informatif.
|
||
*/
|
||
final class PlanningController
|
||
{
|
||
private PlanningModel $planningModel;
|
||
private AgentModel $agentModel;
|
||
private StructureModel $structureModel;
|
||
private MotifModel $motifModel;
|
||
private QuotaModel $quotaModel;
|
||
private VacationModel $vacationModel;
|
||
private Access $access;
|
||
|
||
/**
|
||
* Les modèles sont instanciés d'emblée plutôt qu'à la demande : ils ne
|
||
* portent aucun état, et cela évite d'éparpiller des `new` dans les méthodes.
|
||
*/
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* GET api/planning.php — état complet d'une semaine de saisie.
|
||
*
|
||
* Renvoie le planning et ses créneaux, mais aussi les créneaux de l'agent
|
||
* sur les AUTRES lieux la même semaine (`other_entries`) : un agent peut
|
||
* être planifié sur plusieurs lieux, et l'éditeur doit les afficher en
|
||
* lecture seule pour rendre les chevauchements visibles avant l'échec.
|
||
*
|
||
* Paramètres GET : structure_id, agent_id, week (format ISO « 2026-W08 »).
|
||
*/
|
||
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),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* POST save_planning.php — enregistre le brouillon d'une semaine.
|
||
*
|
||
* La charge utile remplace INTÉGRALEMENT les créneaux de la semaine : une
|
||
* liste `entries` vide est un cas nominal (« tout supprimer »), pas une
|
||
* erreur de saisie. D'où le message spécifique renvoyé dans ce cas.
|
||
*
|
||
* Ordre des contrôles, du moins coûteux au plus coûteux, et surtout du plus
|
||
* explicite au plus générique :
|
||
* 1. droits et périmètre ;
|
||
* 2. motifs fermés à l'agent (message nommant le motif) ;
|
||
* 3. normalisation/validation ligne à ligne ;
|
||
* 4. chevauchements internes à la saisie ;
|
||
* 5. chevauchements avec les autres lieux.
|
||
*
|
||
* Charge utile : structure_id, agent_id, week, entries[], csrf_token.
|
||
*/
|
||
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->assertDatesWithinContract($agent, array_column($entries, 'date'));
|
||
$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) {
|
||
// INCOHÉRENCE CONNUE : ce test vaut « actif » quand PTA_DEBUG n'est
|
||
// PAS défini (défaut '1'), alors que Security::debugEnabled() —
|
||
// référence documentée — vaut « inactif » par défaut. Le détail
|
||
// technique de l'exception peut donc remonter au navigateur sur une
|
||
// installation qui ne définit pas la variable. Le motif est répété
|
||
// dans les huit contrôleurs ; à unifier sur Security::debugEnabled().
|
||
JsonResponse::send(500, [
|
||
'error' => 'Erreur lors de l’enregistrement du planning.',
|
||
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
|
||
]);
|
||
}
|
||
|
||
Journal::ecriture(
|
||
'PLANNING_ENREGISTRE',
|
||
'planning',
|
||
$planningId,
|
||
sprintf('Brouillon enregistré : %d créneau(x) pour l’agent #%d, semaine %s.', count($entries), (int) $agentId, $weekValue),
|
||
['id_agent' => (int) $agentId, 'id_structure' => (int) $structureId, 'semaine' => $weekValue]
|
||
);
|
||
|
||
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),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* POST api/update_agent_entry.php — modifie un créneau depuis la fiche agent.
|
||
*
|
||
* Un créneau peut appartenir à un planning déjà VALIDÉ : le modèle le
|
||
* repasse alors automatiquement en BROUILLON et renvoie les identifiants
|
||
* concernés, d'où le drapeau `requires_revalidation` dans la réponse. Le
|
||
* message doit le dire explicitement, sans quoi l'utilisateur croirait sa
|
||
* validation intacte.
|
||
*
|
||
* Charge utile : entry_id, structure_id, motif_id, date, start, end, csrf_token.
|
||
*/
|
||
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.']);
|
||
}
|
||
|
||
// Le « ! » de createFromFormat remet à zéro les champs non fournis :
|
||
// sans lui, l'heure courante serait injectée et la comparaison de
|
||
// contrôle ci-dessous laisserait passer des dates approximatives.
|
||
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
|
||
$validDate = $dateObj && $dateObj->format('Y-m-d') === $date;
|
||
// La saisie est contrainte au quart d'heure : c'est la granularité de
|
||
// toute l'application (éditeur, cumuls, PDF).
|
||
$quarterHour = static fn(string $time): bool => (bool) preg_match('/^(?:[01]\d|2[0-3]):(?:00|15|30|45)$/', $time);
|
||
|
||
// N > 5 : samedi et dimanche. Le modèle métier s'arrête au vendredi.
|
||
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.']);
|
||
}
|
||
$this->assertDatesWithinContract($entryAgent, [$date]);
|
||
|
||
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;
|
||
Journal::ecriture(
|
||
'CRENEAU_MODIFIE',
|
||
'creneau_horaire',
|
||
(int) $entryId,
|
||
sprintf('Créneau du %s modifié (%s-%s) pour l’agent #%d.', $date, $start, $end, (int) $result['agent_id']),
|
||
['id_agent' => (int) $result['agent_id'], 'id_structure' => (int) $structureId, 'revalidation' => $reopened]
|
||
);
|
||
|
||
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),
|
||
]);
|
||
}
|
||
|
||
|
||
/**
|
||
* POST api/delete_agent_entry.php — supprime un créneau depuis la fiche agent.
|
||
*
|
||
* Comme updateAgentEntry(), peut rouvrir un planning validé. Le planning
|
||
* n'est jamais supprimé même s'il devient vide : `planning_empty` permet à
|
||
* l'interface de le signaler, mais l'enregistrement reste et conserve son
|
||
* historique.
|
||
*
|
||
* Charge utile : entry_id, csrf_token.
|
||
*/
|
||
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.';
|
||
}
|
||
|
||
Journal::ecriture(
|
||
'CRENEAU_SUPPRIME',
|
||
'creneau_horaire',
|
||
(int) $entryId,
|
||
sprintf('Créneau du %s supprimé pour l’agent #%d.', (string) $result['date'], (int) $result['agent_id']),
|
||
['id_agent' => (int) $result['agent_id'], 'revalidation' => (bool) $result['reopened']]
|
||
);
|
||
|
||
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']),
|
||
]);
|
||
}
|
||
|
||
|
||
/**
|
||
* POST api/move_or_duplicate_entry.php — glisser-déposer d'un créneau.
|
||
*
|
||
* Sert les deux gestes de l'éditeur : déplacer (`move`) et dupliquer
|
||
* (`duplicate`, glisser avec Ctrl). Déposer un créneau sur son jour d'origine
|
||
* n'est pas une erreur : le modèle renvoie l'action « none » et le message
|
||
* l'indique sans alarmer.
|
||
*
|
||
* Charge utile : entry_id, target_date, action (move|duplicate), csrf_token.
|
||
*/
|
||
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.']);
|
||
}
|
||
|
||
$entryAgent = $this->agentModel->findActive((int) $entry['id_agent']);
|
||
if ($entryAgent === null) {
|
||
JsonResponse::send(422, ['error' => 'Agent invalide ou inactif.']);
|
||
}
|
||
$this->assertDatesWithinContract($entryAgent, [$targetDate]);
|
||
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.';
|
||
}
|
||
|
||
Journal::ecriture(
|
||
$result['action'] === 'duplicate' ? 'CRENEAU_DUPLIQUE' : 'CRENEAU_DEPLACE',
|
||
'creneau_horaire',
|
||
(int) $entryId,
|
||
sprintf('Créneau reporté au %s pour l’agent #%d.', $targetDate, (int) $result['agent_id']),
|
||
['id_agent' => (int) $result['agent_id'], 'action' => $result['action'], 'revalidation' => $reopened]
|
||
);
|
||
|
||
JsonResponse::send(200, [
|
||
'success' => true,
|
||
'action' => $result['action'],
|
||
'message' => $message,
|
||
'requires_revalidation' => $reopened,
|
||
'quota' => $this->quotaModel->forDateForApi((int) $result['agent_id'], $targetDate),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* POST api/copy_agent_week.php — recopie une semaine entière d'un agent.
|
||
*
|
||
* Réservé au Service Enfance (requireService) et non aux responsables : la
|
||
* copie porte sur TOUS les lieux de l'agent sur la semaine source, elle
|
||
* déborde donc par nature du périmètre d'un responsable de structure.
|
||
*
|
||
* Deux modes : `merge` ajoute aux créneaux existants de la semaine cible,
|
||
* `replace` les remplace.
|
||
*
|
||
* Charge utile : agent_id, source_week, target_week, mode, csrf_token.
|
||
*/
|
||
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.']);
|
||
}
|
||
$agent = $this->agentModel->findActive((int) $agentId);
|
||
if ($agent === null) {
|
||
JsonResponse::send(404, ['error' => 'Agent introuvable ou inactif.']);
|
||
}
|
||
// La copie ne connaît pas d'avance les jours réellement recopiés : on
|
||
// contrôle les cinq jours ouvrés de la semaine cible, ce qui refuse une
|
||
// semaine à cheval sur la fin d'un contrat aussi bien qu'une semaine
|
||
// entièrement hors contrat.
|
||
$this->assertDatesWithinContract($agent, $this->workingDaysOfWeek($targetWeek[0], $targetWeek[1]));
|
||
|
||
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.';
|
||
}
|
||
|
||
Journal::ecriture(
|
||
'SEMAINE_COPIEE',
|
||
'planning',
|
||
null,
|
||
sprintf(
|
||
'%d créneau(x) copiés de la semaine %s vers %s pour l’agent #%d.',
|
||
(int) $result['copied_entries'],
|
||
$sourceWeekValue,
|
||
$targetWeekValue,
|
||
(int) $agentId
|
||
),
|
||
[
|
||
'id_agent' => (int) $agentId,
|
||
'semaine_source' => $sourceWeekValue,
|
||
'semaine_cible' => $targetWeekValue,
|
||
'mode' => $mode,
|
||
]
|
||
);
|
||
|
||
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')
|
||
),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* GET api/pending_plannings.php — file d'attente de validation.
|
||
*
|
||
* Alimente l'écran pending.php. Les totaux distinguent les minutes saisies
|
||
* (`total_minutes`) des minutes réellement décomptées du quota
|
||
* (`counted_minutes`) : congés et absences non décomptées figurent au
|
||
* planning sans peser sur le quota annuel.
|
||
*/
|
||
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,
|
||
],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* POST validate_planning.php — contrôle puis valide un planning.
|
||
*
|
||
* C'est ICI que vivent les règles de validation réellement appliquées (et
|
||
* non dans sp_valider_planning côté SQL, jamais appelée).
|
||
*
|
||
* Quatre contrôles bloquants (ERREUR → 422, aucune validation) :
|
||
* - AUCUN_CRENEAU : planning vide ;
|
||
* - CRENEAU_HORS_SEMAINE : créneau hors des bornes de la semaine ;
|
||
* - CHEVAUCHEMENT_INTERNE : deux créneaux du planning se recouvrent ;
|
||
* - HORS_CONTRAT : affectation antérieure à l'arrivée de l'agent ou
|
||
* postérieure à son départ (V7.15). Les gardes d'écriture empêchent d'en
|
||
* créer, ce contrôle attrape celles qui préexistaient ou qu'un contrat
|
||
* raccourci a rendues illégitimes ;
|
||
* - CHEVAUCHEMENT_AUTRE_STRUCTURE : recouvrement avec un créneau DÉJÀ
|
||
* VALIDÉ sur un autre lieu. Seuls les plannings validés sont opposables :
|
||
* un brouillon concurrent peut encore être corrigé, le bloquer
|
||
* empêcherait toute validation dans un ordre donné.
|
||
*
|
||
* Un contrôle non bloquant (ALERTE → 409) :
|
||
* - QUOTA_DEPASSE : le quota annuel est dépassé. La validation reste
|
||
* possible, mais exige un rappel explicite avec force_quota: true. Le
|
||
* dépassement est une décision de gestion, pas une anomalie technique.
|
||
*
|
||
* Tous les contrôles sont collectés avant de trancher, afin que l'utilisateur
|
||
* voie l'ensemble des problèmes d'un coup plutôt qu'un par requête.
|
||
*
|
||
* Charge utile : planning_id, force_quota (bool), csrf_token.
|
||
*/
|
||
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->entriesOutsideContract((int) $planningId) as $row) {
|
||
$this->addControl(
|
||
$controls,
|
||
'ERREUR',
|
||
'HORS_CONTRAT',
|
||
sprintf(
|
||
'L’affectation #%d du %s est en dehors des dates de contrat de l’agent.',
|
||
$row['id_creneau'],
|
||
$row['date_jour']
|
||
)
|
||
);
|
||
}
|
||
|
||
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é.');
|
||
|
||
// Journalisé APRÈS le passage en base : une validation refusée par les
|
||
// contrôles métier plus haut n'a pas eu lieu, elle n'a rien à tracer.
|
||
Journal::ecriture(
|
||
'PLANNING_VALIDE',
|
||
'planning',
|
||
(int) $planningId,
|
||
sprintf(
|
||
'Planning validé pour l’agent #%d, semaine du %s.',
|
||
(int) $planning['id_agent'],
|
||
(string) $planning['date_debut']
|
||
),
|
||
[
|
||
'id_agent' => (int) $planning['id_agent'],
|
||
'id_structure' => isset($planning['id_structure']) ? (int) $planning['id_structure'] : null,
|
||
'date_debut' => (string) $planning['date_debut'],
|
||
'quota_force' => $forceQuota,
|
||
]
|
||
);
|
||
|
||
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.',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* POST reopen_planning.php — repasse un planning VALIDÉ en BROUILLON.
|
||
*
|
||
* Réouverture volontaire, à distinguer de la réouverture automatique
|
||
* déclenchée par la modification d'un créneau. Le modèle applique un UPDATE
|
||
* conditionné au statut VALIDE : un retour à zéro ligne signifie qu'un autre
|
||
* utilisateur est passé entre-temps, d'où le 409 plutôt qu'un succès
|
||
* trompeur.
|
||
*
|
||
* Charge utile : planning_id, csrf_token.
|
||
*/
|
||
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.']);
|
||
}
|
||
|
||
Journal::ecriture(
|
||
'PLANNING_ROUVERT',
|
||
'planning',
|
||
(int) $planningId,
|
||
sprintf(
|
||
'Planning repassé en brouillon pour l’agent #%d, semaine du %s.',
|
||
(int) $planning['id_agent'],
|
||
(string) $planning['date_debut']
|
||
),
|
||
['id_agent' => (int) $planning['id_agent'], 'date_debut' => (string) $planning['date_debut']]
|
||
);
|
||
|
||
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']),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Décode une semaine au format de l'input HTML `type="week"` (« 2026-W08 »).
|
||
*
|
||
* La borne haute est 53 et non 52 : les années ISO longues (2020, 2026…)
|
||
* comptent bien une semaine 53.
|
||
*
|
||
* @return array{0: int, 1: int}|null [année ISO, numéro de semaine], ou null.
|
||
*/
|
||
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'] ?? '')
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Valide et met en forme les créneaux reçus du client.
|
||
*
|
||
* Rien de ce qui vient du navigateur n'est repris tel quel : date réelle,
|
||
* motif appartenant à la liste ouverte à CET agent, horaires au quart
|
||
* d'heure, fin après début, et appartenance à la semaine visée. Toute ligne
|
||
* fautive interrompt la requête en 422 en nommant son numéro, seul repère
|
||
* utilisable par l'utilisateur pour la retrouver dans l'éditeur.
|
||
*
|
||
* Les heures ressortent en HH:MM:SS, format attendu par les colonnes TIME.
|
||
*
|
||
* @param array<int, mixed> $entries Créneaux bruts issus du JSON.
|
||
* @param array<int, int> $validMotifs Identifiants de motifs autorisés.
|
||
*
|
||
* @return array<int, array{date: string, motif_id: int, start: string, end: string}>
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Refuse deux créneaux qui se recouvrent dans la MÊME saisie.
|
||
*
|
||
* Comparaison en O(n²), assumée : une semaine plafonne à quelques dizaines
|
||
* de créneaux. Le test `debut1 < fin2 && fin1 > debut2` est un recouvrement
|
||
* strict — deux créneaux jointifs (10:00-12:00 et 12:00-14:00) sont valides.
|
||
*
|
||
* @param array<int, array{date: string, start: string, end: string}> $entries
|
||
*/
|
||
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',
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Refuse un recouvrement avec un créneau de l'agent sur un AUTRE lieu.
|
||
*
|
||
* À la différence de validate(), ce contrôle-ci oppose aussi les brouillons :
|
||
* un agent ne peut pas être à deux endroits à la fois, même en cours de
|
||
* saisie. La réponse porte un objet `conflict` détaillé pour que l'interface
|
||
* puisse nommer le lieu et l'horaire en cause.
|
||
*
|
||
* Les horaires stockés arrivent en HH:MM et sont ramenés en HH:MM:SS pour
|
||
* être comparables aux créneaux normalisés.
|
||
*
|
||
* @param array<int, array{date: string, start: string, end: string}> $entries
|
||
* @param array<int, array<string, mixed>> $existingEntries
|
||
*/
|
||
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'],
|
||
],
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Empile un contrôle au format commun {niveau, code, message}.
|
||
*
|
||
* @param array<int, array{niveau: string, code: string, message: string}> $controls
|
||
* @param string $level ERREUR (bloquant), ALERTE (confirmable) ou INFO.
|
||
*/
|
||
/**
|
||
* Refuse toute affectation hors des dates de contrat de l'agent (V7.15).
|
||
*
|
||
* Un agent n'existe pour PTA qu'entre sa date de début et sa date de fin de
|
||
* contrat : le planifier avant son arrivée ou après son départ produit des
|
||
* heures que personne ne fera, et fausse les cumuls annuels comme les
|
||
* contrôles de couverture. La règle s'applique à TOUS les motifs, congés et
|
||
* absences compris — hors contrat, il n'y a rien à décompter.
|
||
*
|
||
* Toutes les dates sont contrôlées d'un coup pour que l'utilisateur voie
|
||
* l'ensemble des jours refusés, et non le premier.
|
||
*
|
||
* @param array<string, mixed> $agent Fiche agent.
|
||
* @param array<int, string> $dates Dates AAAA-MM-JJ visées par l'écriture.
|
||
*/
|
||
private function assertDatesWithinContract(array $agent, array $dates): void
|
||
{
|
||
$hors = AgentModel::datesOutsideContract($agent, $dates);
|
||
if ($hors === []) {
|
||
return;
|
||
}
|
||
|
||
JsonResponse::send(422, [
|
||
'error' => AgentModel::contractViolationMessage($agent, $hors),
|
||
'code' => 'HORS_CONTRAT',
|
||
'dates' => $hors,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Les cinq jours ouvrés d'une semaine ISO, au format AAAA-MM-JJ.
|
||
* Le modèle métier s'arrête au vendredi : samedi et dimanche n'ont pas à
|
||
* être contrôlés, aucun créneau ne peut y être posé.
|
||
*
|
||
* @return array<int, string>
|
||
*/
|
||
private function workingDaysOfWeek(int $year, int $weekNumber): array
|
||
{
|
||
$lundi = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
|
||
|
||
return array_map(
|
||
static fn(int $offset): string => $lundi->modify('+' . $offset . ' day')->format('Y-m-d'),
|
||
range(0, 4)
|
||
);
|
||
}
|
||
|
||
private function addControl(array &$controls, string $level, string $code, string $message): void
|
||
{
|
||
$controls[] = ['niveau' => $level, 'code' => $code, 'message' => $message];
|
||
}
|
||
}
|