Files
pta/app/Controllers/AgentController.php

747 lines
34 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\ContactFormat;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
use App\Core\PdfResponse;
use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
use App\Services\AgentHoursContractPdfService;
use App\Services\AgentTimeSummaryPdfService;
use App\Services\PlanningPdfService;
use DateTimeImmutable;
use PDO;
use Throwable;
/**
* Fiche agent : consultation, référentiel des agents et documents PDF.
*
* Deux familles de méthodes cohabitent ici, avec des conventions d'erreur
* DIFFÉRENTES et qu'il ne faut pas mélanger :
* - les endpoints JSON, qui répondent via JsonResponse::send() ;
* - les endpoints PDF (pdf, timeSummaryPdf, hoursContractPdf), qui produisent
* un flux binaire. Leurs points d'entrée n'appellent PAS
* JsonResponse::bootstrap(), et ils signalent donc leurs erreurs par un
* http_response_code() suivi d'un `return` et d'un message en texte brut.
* Y écrire du JSON corromprait le document téléchargé.
*/
final class AgentController
{
private AgentModel $agentModel;
private StructureModel $structureModel;
private QuotaModel $quotaModel;
private Access $access;
public function __construct(private PDO $pdo)
{
$this->agentModel = new AgentModel($pdo);
$this->structureModel = new StructureModel($pdo);
$this->quotaModel = new QuotaModel($pdo);
$this->access = new Access($pdo);
}
/**
* GET api/agents.php — agents sélectionnables dans l'éditeur de planning.
*
* structure_id à 0 (ou absent) signifie « tous les lieux » : ce n'est licite
* que pour le Service Enfance, un responsable étant renvoyé en 403 par
* requireStructureEdit() dès qu'il vise un autre lieu que le sien.
*/
public function listForPlanning(): void
{
$this->access->requireDraftEditor();
$selectedLocation = filter_input(INPUT_GET, 'structure_id', FILTER_VALIDATE_INT) ?: 0;
if ($selectedLocation) {
$this->access->requireStructureEdit((int) $selectedLocation);
}
JsonResponse::send(200, ['agents' => $this->agentModel->listForLocation((int) $selectedLocation)]);
}
/**
* GET api/agent_overview.php — semaine d'un agent, tous lieux confondus.
*
* Vue transverse par agent (et non par lieu comme api/planning.php) : c'est
* la seule qui donne la charge réelle d'un agent planifié à plusieurs
* endroits. En lecture seule pour le rôle AGENT.
*
* Paramètres GET : agent_id, week (« 2026-W08 »).
*/
public function overview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
if (!$agentId || $week === null) {
JsonResponse::send(400, ['error' => 'Agent ou semaine invalide.']);
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
[$year, $weekNumber] = $week;
$weekStart = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1)->format('Y-m-d');
$weekEnd = (new DateTimeImmutable())->setISODate($year, $weekNumber, 7)->format('Y-m-d');
JsonResponse::send(200, [
'agent' => $agent,
'week' => ['value' => $weekValue, 'date_debut' => $weekStart, 'date_fin' => $weekEnd],
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $weekStart),
'entries' => $this->agentModel->entriesForWeek((int) $agentId, $year, $weekNumber),
]);
}
/**
* GET api/agent_month_overview.php — grille mensuelle d'un agent.
*
* Construit un calendrier lundi→vendredi ALIGNÉ SUR LES SEMAINES ISO : la
* grille commence au lundi de la semaine du 1er du mois et finit au vendredi
* de la semaine du dernier jour. Elle déborde donc du mois, et le drapeau
* `in_month` de chaque jour permet à l'interface de griser les débordements.
* Sans cet alignement, une semaine serait coupée en deux et les cumuls
* hebdomadaires affichés seraient faux.
*
* Les créneaux sont chargés sur toute l'étendue de la grille, débordements
* compris, pour que les totaux de semaine restent justes.
*
* Paramètres GET : agent_id, month (« 2026-02 »).
*/
public function monthOverview(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$monthValue = trim((string) ($_GET['month'] ?? ''));
if (!$agentId || !preg_match('/^(\d{4})-(\d{2})$/', $monthValue, $matches)) {
JsonResponse::send(400, ['error' => 'Agent ou mois invalide.']);
}
$this->access->requireAgentView((int) $agentId);
$year = (int) $matches[1];
$monthNumber = (int) $matches[2];
if ($monthNumber < 1 || $monthNumber > 12) {
JsonResponse::send(400, ['error' => 'Mois invalide.']);
}
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
$firstDay = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $monthNumber));
$lastDay = $firstDay->modify('last day of this month');
$gridStart = $firstDay->modify('-' . ((int) $firstDay->format('N') - 1) . ' days');
$lastWeekMonday = $lastDay->modify('-' . ((int) $lastDay->format('N') - 1) . ' days');
$gridEnd = $lastWeekMonday->modify('+4 days');
$dayLabels = [1 => 'Lundi', 2 => 'Mardi', 3 => 'Mercredi', 4 => 'Jeudi', 5 => 'Vendredi'];
$monthLabels = [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril', 5 => 'Mai', 6 => 'Juin',
7 => 'Juillet', 8 => 'Août', 9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
];
$weeks = [];
for ($monday = $gridStart; $monday <= $lastWeekMonday; $monday = $monday->modify('+7 days')) {
$days = [];
for ($weekday = 1; $weekday <= 5; $weekday++) {
$date = $monday->modify('+' . ($weekday - 1) . ' days');
$days[] = [
'date' => $date->format('Y-m-d'),
'label' => $dayLabels[$weekday],
'display' => $date->format('d/m'),
'in_month' => (int) $date->format('n') === $monthNumber,
];
}
$weeks[] = [
'value' => sprintf('%04d-W%02d', (int) $monday->format('o'), (int) $monday->format('W')),
'number' => (int) $monday->format('W'),
'date_debut' => $monday->format('Y-m-d'),
'date_fin' => $monday->modify('+4 days')->format('Y-m-d'),
'days' => $days,
];
}
JsonResponse::send(200, [
'agent' => $agent,
'month' => [
'value' => $monthValue,
'year' => $year,
'month' => $monthNumber,
'label' => $monthLabels[$monthNumber] . ' ' . $year,
'date_debut' => $firstDay->format('Y-m-d'),
'date_fin' => $lastDay->format('Y-m-d'),
'grid_start' => $gridStart->format('Y-m-d'),
'grid_end' => $gridEnd->format('Y-m-d'),
],
'weeks' => $weeks,
'quota' => $this->quotaModel->forDateForApi((int) $agentId, $firstDay->format('Y-m-d')),
'entries' => $this->agentModel->entriesForDateRange(
(int) $agentId,
$gridStart->format('Y-m-d'),
$gridEnd->format('Y-m-d')
),
]);
}
/**
* GET api/agent_time_summary_pdf.php — bilan annuel d'heures (PDF).
*
* La période couverte n'est pas choisie par l'appelant : elle est celle
* calculée par QuotaModel (année civile 01/01 → 31/12), d'où la lecture de
* date_debut_periode / date_fin_periode dans le quota. Le paramètre `year`
* n'est qu'un raccourci vers le 1er janvier correspondant.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function timeSummaryPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$dateValue = trim((string) ($_GET['date'] ?? ''));
$year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT);
if ($dateValue === '' && $year) {
$dateValue = sprintf('%04d-01-01', (int) $year);
}
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $dateValue);
if (!$agentId || !$date || $date->format('Y-m-d') !== $dateValue) {
http_response_code(400);
echo 'Agent ou date de référence invalide.';
return;
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
http_response_code(404);
echo 'Agent introuvable.';
return;
}
$quota = $this->quotaModel->forDateForApi((int) $agentId, $dateValue);
$summary = $this->agentModel->timeSummaryForRange(
(int) $agentId,
(string) $quota['date_debut_periode'],
(string) $quota['date_fin_periode']
);
$pdf = (new AgentTimeSummaryPdfService())->annualSummary($agent, $quota, $summary);
PdfResponse::inline(
$pdf,
sprintf(
'bilan_heures_%s_%s_%s_%s.pdf',
$agent['nom'],
$agent['prenom'],
str_replace('-', '', (string) $quota['date_debut_periode']),
str_replace('-', '', (string) $quota['date_fin_periode'])
)
);
}
/**
* GET api/agent_planning_pdf.php — planning d'un agent sur plusieurs semaines.
*
* `weeks` n'accepte que 6 ou 7 (7 par défaut) : au-delà, la grille ne tient
* plus sur une page A4 paysage et SimplePdf ne gère pas le report de tableau
* sur page suivante.
*
* Paramètres GET : agent_id, week (semaine de départ), weeks (6|7).
*/
public function pdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$weekValue = trim((string) ($_GET['week'] ?? ''));
$week = $this->parseWeek($weekValue);
$weekCount = filter_input(INPUT_GET, 'weeks', FILTER_VALIDATE_INT) ?: 7;
$weekCount = in_array((int) $weekCount, [6, 7], true) ? (int) $weekCount : 7;
if (!$agentId || $week === null) {
http_response_code(400);
echo 'Agent ou semaine invalide.';
return;
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
http_response_code(404);
echo 'Agent introuvable.';
return;
}
[$year, $weekNumber] = $week;
$firstMonday = (new DateTimeImmutable())->setISODate($year, $weekNumber, 1);
$weeks = [];
for ($index = 0; $index < $weekCount; $index++) {
$monday = $firstMonday->modify('+' . ($index * 7) . ' days');
$isoYear = (int) $monday->format('o');
$isoWeek = (int) $monday->format('W');
$weeks[] = [
'value' => sprintf('%04d-W%02d', $isoYear, $isoWeek),
'date_debut' => $monday->format('Y-m-d'),
'date_fin' => $monday->modify('+6 days')->format('Y-m-d'),
];
}
$lastFriday = $firstMonday->modify('+' . (($weekCount - 1) * 7 + 4) . ' days');
$entries = $this->agentModel->entriesForDateRange(
(int) $agentId,
$firstMonday->format('Y-m-d'),
$lastFriday->format('Y-m-d')
);
$pdf = (new PlanningPdfService())->agentWeeks(
$agent,
$entries,
$weeks,
$this->quotaModel->forDateForApi((int) $agentId, $firstMonday->format('Y-m-d'))
);
PdfResponse::inline(
$pdf,
sprintf(
'planning_%s_%s_%s_%d_semaines.pdf',
$agent['nom'],
$agent['prenom'],
str_replace('-W', '_S', $weekValue),
$weekCount
)
);
}
/**
* GET api/agent_hours_contract_pdf.php — contrat horaire annuel (PDF).
*
* Document contractuel : il n'affiche que le volume annualisé dû, sans le
* détail des créneaux planifiés.
*
* Paramètres GET : agent_id, puis date OU year.
*/
public function hoursContractPdf(): void
{
$agentId = filter_input(INPUT_GET, 'agent_id', FILTER_VALIDATE_INT);
$dateValue = trim((string) ($_GET['date'] ?? ''));
$year = filter_input(INPUT_GET, 'year', FILTER_VALIDATE_INT);
if ($dateValue === '' && $year) {
$dateValue = sprintf('%04d-01-01', (int) $year);
}
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $dateValue);
if (!$agentId || !$date || $date->format('Y-m-d') !== $dateValue) {
http_response_code(400);
echo 'Agent ou date de référence invalide.';
return;
}
$this->access->requireAgentView((int) $agentId);
$agent = $this->agentModel->findActive((int) $agentId);
if ($agent === null) {
http_response_code(404);
echo 'Agent introuvable.';
return;
}
$quota = $this->quotaModel->forDateForApi((int) $agentId, $dateValue);
$pdf = (new AgentHoursContractPdfService())->annualContract($agent, $quota);
PdfResponse::inline(
$pdf,
sprintf(
'contrat_horaire_%s_%s_%s.pdf',
$agent['nom'],
$agent['prenom'],
str_replace('-', '', (string) $quota['date_debut_periode'])
)
);
}
/**
* api/agent_assignments.php — rattachement principal d'un agent.
*
* Endpoint à double verbe, exception au reste de l'API : GET liste les
* agents, POST modifie un rattachement. Le GET sort par JsonResponse::send()
* qui termine le script, ce qui rend le `Request::requireMethod('POST')`
* suivant inatteignable en lecture — le flux tombe donc bien en séquence.
*
* Un structure_id nul est VALIDE : il détache l'agent de tout lieu par
* défaut, sans l'empêcher d'être planifié ponctuellement ailleurs.
*
* Charge utile (POST) : agent_id, structure_id (nullable), csrf_token.
*/
public function assignments(): void
{
$this->access->requireService();
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
JsonResponse::send(200, ['agents' => $this->agentModel->allActive()]);
}
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$rawStructure = $payload['structure_id'] ?? null;
$structureId = ($rawStructure === null || $rawStructure === '' || $rawStructure === 0)
? null
: filter_var($rawStructure, FILTER_VALIDATE_INT);
if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
if ($this->agentModel->findActive((int) $agentId) === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation invalide ou inactif.']);
}
try {
$this->agentModel->updateAssignment((int) $agentId, $structureId !== null ? (int) $structureId : null);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de modifier les paramètres de l’agent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
Journal::ecriture(
'AGENT_RATTACHEMENT_MODIFIE',
'agent',
(int) $agentId,
$structureId === null
? sprintf('Lieu principal retiré à l’agent #%d.', (int) $agentId)
: sprintf('Lieu principal de l’agent #%d changé pour le lieu #%d.', (int) $agentId, (int) $structureId),
['id_structure' => $structureId !== null ? (int) $structureId : null]
);
JsonResponse::send(200, [
'message' => $structureId === null
? 'Les paramètres de l’agent ont été mis à jour sans lieu principal.'
: 'Le lieu principal de l’agent a été mis à jour.',
]);
}
/**
* POST api/create_agent.php — création d'un agent.
*
* Réservé au Service Enfance. Les contrôles vont au-delà du « non vide » :
* poste existant en base, type de contrat dans l'énumération, dates
* cohérentes entre elles, quotité dans ]0;100], longueurs alignées sur les
* colonnes SQL (une chaîne trop longue serait sinon tronquée silencieusement
* par MariaDB), et unicité du matricule (409, pas 422 : c'est un conflit
* avec l'existant, pas une saisie malformée).
*
* La réponse renvoie l'agent complet reconstitué, pour que l'interface
* l'insère dans sa liste sans avoir à recharger toute la page.
*/
public function create(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$matricule = trim((string) ($payload['matricule'] ?? ''));
$nom = trim((string) ($payload['nom'] ?? ''));
$prenom = trim((string) ($payload['prenom'] ?? ''));
$email = trim((string) ($payload['email'] ?? ''));
$telephone = trim((string) ($payload['telephone'] ?? ''));
$adresse = trim((string) ($payload['adresse'] ?? ''));
$estDiplome = filter_var($payload['est_diplome'] ?? false, FILTER_VALIDATE_BOOLEAN);
$diplomeLibelle = trim((string) ($payload['diplome_libelle'] ?? ''));
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT);
$rate = filter_var($payload['quotite_travail'] ?? null, FILTER_VALIDATE_FLOAT);
$contractStart = trim((string) ($payload['date_debut_contrat'] ?? ''));
$contractStartDate = DateTimeImmutable::createFromFormat('!Y-m-d', $contractStart);
$contractEnd = trim((string) ($payload['date_fin_contrat'] ?? ''));
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
if ($matricule === '' || $nom === '' || $prenom === '' || !$structureId || !$postId
|| !in_array((int) $postId, $validPostIds, true)
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
JsonResponse::send(422, ['error' => 'Le matricule, le nom, le prénom, le poste, le type de contrat, le lieu principal et des dates de contrat cohérentes sont obligatoires.']);
}
if ($rate === false || $rate <= 0 || $rate > 100) {
JsonResponse::send(422, ['error' => 'La quotité de travail doit être comprise entre 1 et 100 %.']);
}
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
// Les règles de forme vivent dans ContactFormat, communes à create() et
// update(), et recopiées dans assets/js/field-format.js pour le retour
// immédiat à la saisie.
if ($email !== '' && !ContactFormat::isEmail($email)) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
}
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
}
if (strlen($matricule) > 50 || strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255
|| strlen($telephone) > 30 || strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
}
if ($this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Le lieu d’affectation principal sélectionné est invalide ou inactif.']);
}
if ($this->agentModel->matriculeExists($matricule)) {
JsonResponse::send(409, ['error' => 'Un agent utilise déjà ce matricule.']);
}
try {
$created = $this->agentModel->createWithDefaults([
'matricule' => $matricule,
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null,
'est_diplome' => $estDiplome,
'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null,
'structure_id' => (int) $structureId,
'quotite_travail' => (float) $rate,
'date_debut_contrat' => $contractStart,
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]);
} catch (Throwable $e) {
// Seul endroit du projet qui utilise correctement
// Security::debugEnabled() (fermé par défaut). Les autres blocs
// catch testent `getenv('PTA_DEBUG') ?: '1'`, dont le défaut est
// OUVERT — voir la note dans PlanningController::save().
JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.',
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}
$structure = $this->structureModel->findActive((int) $structureId);
Journal::ecriture(
'AGENT_CREE',
'agent',
isset($created['id_agent']) ? (int) $created['id_agent'] : null,
sprintf('Création de l’agent %s %s (matricule %s).', $prenom, $nom, $matricule),
['matricule' => $matricule, 'id_structure' => (int) $structureId, 'type_contrat' => $contractType]
);
JsonResponse::send(201, [
'message' => sprintf('%s %s a été ajouté avec le lieu principal « %s ».', $prenom, $nom, $structure['nom']),
'agent' => array_merge($created, [
'matricule' => $matricule,
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null,
'est_diplome' => $estDiplome ? 1 : 0,
'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null,
'id_structure' => (int) $structureId,
'structure_nom' => (string) $structure['nom'],
'structure_type_affectation' => (string) $structure['type_affectation'],
'quotite_travail' => (float) $rate,
'date_debut_contrat' => $contractStart,
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'poste_libelle' => (string) (array_values(array_filter($this->agentModel->allPosts(), static fn(array $post): bool => (int) $post['id_poste'] === (int) $postId))[0]['libelle'] ?? ''),
'type_contrat' => $contractType,
'droit_enveloppe_formation' => $trainingRight ? 1 : 0,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
]),
]);
}
/**
* POST api/update_agent.php — modification de la fiche d'un agent.
*
* Mêmes contrôles que create(), matricule excepté : celui-ci n'est jamais
* modifiable, c'est la clé de rapprochement avec les outils RH. Le nom et le
* prénom, EUX, le sont (V7.13) : mariage, divorce, ou simple faute de frappe
* à la saisie initiale — sans cet écran, il fallait passer par SQL.
*
* La quotité de travail n'est pas modifiable ici non plus : elle détermine
* le quota annuel déjà consommé, et la changer en cours d'année fausserait
* rétroactivement tous les suivis.
*/
public function updateProfile(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT);
$nom = trim((string) ($payload['nom'] ?? ''));
$prenom = trim((string) ($payload['prenom'] ?? ''));
$email = trim((string) ($payload['email'] ?? ''));
$telephone = trim((string) ($payload['telephone'] ?? ''));
$adresse = trim((string) ($payload['adresse'] ?? ''));
$estDiplome = filter_var($payload['est_diplome'] ?? false, FILTER_VALIDATE_BOOLEAN);
$diplomeLibelle = trim((string) ($payload['diplome_libelle'] ?? ''));
$contractStart = trim((string) ($payload['date_debut_contrat'] ?? ''));
$contractStartDate = DateTimeImmutable::createFromFormat('!Y-m-d', $contractStart);
$contractEnd = trim((string) ($payload['date_fin_contrat'] ?? ''));
$contractEndDate = $contractEnd !== '' ? DateTimeImmutable::createFromFormat('!Y-m-d', $contractEnd) : null;
$postId = filter_var($payload['id_poste'] ?? null, FILTER_VALIDATE_INT);
$contractType = strtoupper(trim((string) ($payload['type_contrat'] ?? 'PERMANENT')));
// Le droit à l'enveloppe de formation est acquis par défaut : un
// formulaire ancien ou une intégration tierce ne doit pas le retirer.
$trainingRight = filter_var($payload['droit_enveloppe_formation'] ?? true, FILTER_VALIDATE_BOOLEAN);
$ptaComment = trim((string) ($payload['commentaire_pta'] ?? ''));
$validPostIds = array_map(static fn(array $post): int => (int) $post['id_poste'], $this->agentModel->allPosts());
$rawStructure = $payload['structure_id'] ?? null;
$structureId = ($rawStructure === null || $rawStructure === '' || $rawStructure === 0)
? null
: filter_var($rawStructure, FILTER_VALIDATE_INT);
if (!$agentId) {
JsonResponse::send(422, ['error' => 'Agent invalide.']);
}
// La fiche AVANT modification est conservée : elle sert à repérer un
// renommage et à le nommer explicitement dans le journal.
$agentAvant = $this->agentModel->findActive((int) $agentId);
if ($agentAvant === null) {
JsonResponse::send(404, ['error' => 'Agent introuvable.']);
}
if ($nom === '' || $prenom === '') {
JsonResponse::send(422, ['error' => 'Le nom et le prénom de l’agent sont obligatoires.']);
}
if (!$postId || !in_array((int) $postId, $validPostIds, true)
|| !in_array($contractType, ['PERMANENT', 'TEMPORAIRE'], true)
|| !$contractStartDate || $contractStartDate->format('Y-m-d') !== $contractStart
|| ($contractEnd !== '' && (!$contractEndDate || $contractEndDate->format('Y-m-d') !== $contractEnd || $contractEndDate < $contractStartDate))) {
JsonResponse::send(422, ['error' => 'Le poste, le type de contrat et les dates du contrat doivent être valides.']);
}
// Coordonnées FACULTATIVES : seule une valeur renseignée est contrôlée.
// Les règles de forme vivent dans ContactFormat, communes à create() et
// update(), et recopiées dans assets/js/field-format.js pour le retour
// immédiat à la saisie.
if ($email !== '' && !ContactFormat::isEmail($email)) {
JsonResponse::send(422, ['error' => 'L’adresse e-mail saisie n’est pas valide. Format attendu : prenom.nom@exemple.fr']);
}
if ($telephone !== '' && !ContactFormat::isPhone($telephone)) {
JsonResponse::send(422, ['error' => 'Le numéro de téléphone saisi n’est pas valide. Format attendu : 06 12 34 56 78 ou +33 6 12 34 56 78.']);
}
if (strlen($nom) > 100 || strlen($prenom) > 100 || strlen($email) > 255 || strlen($telephone) > 30
|| strlen($adresse) > 255 || strlen($diplomeLibelle) > 150 || strlen($ptaComment) > 1000) {
JsonResponse::send(422, ['error' => 'Un des champs dépasse la longueur autorisée.']);
}
if ($structureId !== null && $this->structureModel->findActive((int) $structureId) === null) {
JsonResponse::send(422, ['error' => 'Lieu d’affectation principal invalide ou inactif.']);
}
// Contrôle MIROIR de celui posé à l'affectation (V7.15) : interdire de
// planifier hors contrat ne sert à rien si l'on peut ensuite déplacer le
// contrat sous des affectations existantes. Le refus nomme la période
// concernée, pour que l'utilisateur sache quoi retirer avant de
// recommencer.
$orphelins = $this->agentModel->entriesOutsideContract(
(int) $agentId,
$contractStart,
$contractEnd !== '' ? $contractEnd : null
);
if ($orphelins['nombre'] > 0) {
$lisible = static fn(?string $date): string => $date === null
? '—'
: implode('/', array_reverse(explode('-', $date)));
JsonResponse::send(422, [
'error' => sprintf(
'Ces dates de contrat laisseraient %d affectation(s) en dehors du contrat, entre le %s et le %s. Supprimez-les d’abord, ou élargissez les dates.',
$orphelins['nombre'],
$lisible($orphelins['premiere']),
$lisible($orphelins['derniere'])
),
'code' => 'HORS_CONTRAT',
]);
}
try {
$this->agentModel->updateProfile((int) $agentId, [
'nom' => $nom,
'prenom' => $prenom,
'email' => $email !== '' ? $email : null,
'telephone' => $telephone !== '' ? $telephone : null,
'adresse' => $adresse !== '' ? $adresse : null,
'est_diplome' => $estDiplome,
'diplome_libelle' => $estDiplome && $diplomeLibelle !== '' ? $diplomeLibelle : null,
'date_debut_contrat' => $contractStart,
'date_fin_contrat' => $contractEnd !== '' ? $contractEnd : null,
'id_poste' => (int) $postId,
'type_contrat' => $contractType,
'droit_enveloppe_formation' => $trainingRight,
'commentaire_pta' => $ptaComment !== '' ? $ptaComment : null,
'structure_id' => $structureId !== null ? (int) $structureId : null,
]);
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de modifier les informations de l’agent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
]);
}
// La fiche est relue une seule fois : elle sert à la fois au libellé de
// la trace et à la réponse.
$agent = $this->agentModel->findActive((int) $agentId);
$identiteAvant = trim((string) $agentAvant['prenom'] . ' ' . (string) $agentAvant['nom']);
$identiteApres = trim($prenom . ' ' . $nom);
$renomme = $identiteAvant !== $identiteApres;
Journal::ecriture(
'AGENT_MODIFIE',
'agent',
(int) $agentId,
$renomme
// Un renommage doit se lire dans le journal sans avoir à
// comparer deux lignes : l'ancienne identité y figure en clair.
? sprintf('Modification de la fiche de l’agent %s, renommé depuis « %s ».', $identiteApres, $identiteAvant)
: sprintf('Modification de la fiche de l’agent %s.', $identiteApres),
[
'id_structure' => $structureId !== null ? (int) $structureId : null,
'type_contrat' => $contractType,
'identite_precedente' => $renomme ? $identiteAvant : null,
]
);
JsonResponse::send(200, [
'message' => 'Les informations de l’agent ont été mises à jour.',
'agent' => $agent,
]);
}
/**
* Décode « AAAA-Www ». Dupliqué dans plusieurs contrôleurs, chacun n'ayant
* pas de base commune ; toute correction doit être reportée partout.
*
* @return array{0: int, 1: int}|null [année ISO, semaine], ou null.
*/
private function parseWeek(string $value): ?array
{
if (!preg_match('/^(\d{4})-W(\d{2})$/', $value, $matches)) {
return null;
}
$week = (int) $matches[2];
return $week >= 1 && $week <= 53 ? [(int) $matches[1], $week] : null;
}
}