747 lines
34 KiB
PHP
747 lines
34 KiB
PHP
<?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;
|
||
}
|
||
}
|