pour prod
This commit is contained in:
644
app/Controllers/PlanningController.php
Normal file
644
app/Controllers/PlanningController.php
Normal 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 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.']);
|
||||
}
|
||||
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 d’affectation 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 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.']);
|
||||
}
|
||||
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 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];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user