Files
pta/app/Models/TeamModel.php
2026-08-20 13:22:49 +02:00

343 lines
14 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\Models;
use Throwable;
/**
* Équipes des mercredis et des vacances, et leurs contrôles d'encadrement.
*
* Le NOMBRE D'AGENTS REQUIS est calculé en SQL, au plus contraignant des deux
* règles :
* GREATEST(minimum_agents,
* CEIL(enfants_moins_6 / ratio_moins_6)
* + CEIL(enfants_6_plus / ratio_6_plus))
* Chaque tranche d'âge est arrondie au SUPÉRIEUR séparément avant d'être
* additionnée : un demi-agent n'existe pas, et sommer avant d'arrondir
* sous-estimerait l'encadrement.
*
* Les contrôles suivent la forme commune {niveau, code, message}. Aucun n'est
* bloquant : ils signalent, l'arbitrage restant humain.
*
* Cycle de vie propre aux équipes : BROUILLON → A_CONTROLER → VALIDEE, sans
* transition imposée.
*/
final class TeamModel extends BaseModel
{
/**
* Toutes les équipes, avec leurs membres et leurs contrôles.
*
* Le filtre de dates cherche les équipes qui INTERSECTENT l'intervalle
* (fin >= début demandé ET début <= fin demandée) et non celles qui y sont
* incluses : une équipe à cheval sur les bornes doit remonter.
*
* ATTENTION AU COÛT : deux requêtes supplémentaires par équipe (membres puis
* contrôles). Acceptable sur quelques dizaines d'équipes, à revoir au-delà.
*
* @return array<int, array<string, mixed>>
*/
public function all(?string $startDate = null, ?string $endDate = null): array
{
$sql = "SELECT e.id_equipe, e.id_structure, s.nom AS structure_nom,
e.type_equipe, e.libelle, e.date_debut, e.date_fin,
e.nombre_enfants_moins_6, e.nombre_enfants_6_plus,
e.ratio_moins_6, e.ratio_6_plus, e.minimum_agents,
e.pourcentage_diplomes_min, e.statut, e.commentaire,
GREATEST(e.minimum_agents,
CEIL(e.nombre_enfants_moins_6 / e.ratio_moins_6)
+ CEIL(e.nombre_enfants_6_plus / e.ratio_6_plus)
) AS agents_requis,
COUNT(em.id_agent) AS agents_affectes,
SUM(CASE WHEN a.est_diplome = TRUE THEN 1 ELSE 0 END) AS agents_diplomes,
SUM(CASE WHEN em.fonction_equipe = 'RESPONSABLE' THEN 1 ELSE 0 END) AS responsables
FROM equipe_pta e
JOIN structure s ON s.id_structure = e.id_structure
LEFT JOIN equipe_pta_membre em ON em.id_equipe = e.id_equipe
LEFT JOIN agent a ON a.id_agent = em.id_agent";
$params = [];
$conditions = [];
if ($startDate !== null) {
$conditions[] = 'e.date_fin >= :date_debut';
$params['date_debut'] = $startDate;
}
if ($endDate !== null) {
$conditions[] = 'e.date_debut <= :date_fin';
$params['date_fin'] = $endDate;
}
if ($conditions !== []) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$sql .= " GROUP BY e.id_equipe, e.id_structure, s.nom, e.type_equipe, e.libelle,
e.date_debut, e.date_fin, e.nombre_enfants_moins_6,
e.nombre_enfants_6_plus, e.ratio_moins_6, e.ratio_6_plus,
e.minimum_agents, e.pourcentage_diplomes_min, e.statut,
e.commentaire
ORDER BY e.date_debut, s.nom, e.libelle";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$teams = $stmt->fetchAll();
foreach ($teams as &$team) {
$team['members'] = $this->members((int) $team['id_equipe']);
$team['controls'] = $this->controls($team, $team['members']);
}
unset($team);
return $teams;
}
/**
* Une équipe par identifiant.
*
* Passe par all() plutôt que par une requête dédiée, afin de bénéficier des
* mêmes agrégats et contrôles. Coûteux, mais cette méthode n'est pas sur un
* chemin critique.
*
* @return array<string, mixed>|null
*/
public function find(int $teamId): ?array
{
foreach ($this->all() as $team) {
if ((int) $team['id_equipe'] === $teamId) {
return $team;
}
}
return null;
}
/**
* Crée une équipe, toujours en BROUILLON.
*
* $data est passé tel quel à execute() : ses clés doivent correspondre
* exactement aux paramètres nommés. La validation est faite par
* TeamController::index().
*
* @param array<string, mixed> $data
*
* @return int Identifiant créé.
*/
public function create(array $data): int
{
$stmt = $this->pdo->prepare(
"INSERT INTO equipe_pta (
id_structure, type_equipe, libelle, date_debut, date_fin,
nombre_enfants_moins_6, nombre_enfants_6_plus,
ratio_moins_6, ratio_6_plus, minimum_agents,
pourcentage_diplomes_min, statut, commentaire
) VALUES (
:id_structure, :type_equipe, :libelle, :date_debut, :date_fin,
:nombre_enfants_moins_6, :nombre_enfants_6_plus,
:ratio_moins_6, :ratio_6_plus, :minimum_agents,
:pourcentage_diplomes_min, 'BROUILLON', :commentaire
)"
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
/**
* Ajoute un agent à une équipe, ou met à jour sa fonction s'il y est déjà.
*
* Un seul refus est automatique : une INTERDICTION active de l'agent pour ce
* lieu (agent_structure_souhait). Tout le reste — contraintes médicales,
* temps partiel thérapeutique — est signalé dans les contrôles de l'équipe
* et laissé à l'appréciation humaine, ces situations pouvant être compatibles
* avec certaines affectations.
*
* Les RuntimeException levées ici portent un message destiné à l'utilisateur
* final, que le contrôleur renvoie tel quel en 422.
*
* @throws \RuntimeException Équipe introuvable ou interdiction active.
*/
public function addMember(int $teamId, int $agentId, string $function, ?string $comment): void
{
$team = $this->teamDates($teamId);
if ($team === null) {
throw new \RuntimeException('Équipe introuvable.');
}
$forbidden = $this->pdo->prepare(
"SELECT 1
FROM agent_structure_souhait
WHERE id_agent = :agent_id
AND id_structure = :structure_id
AND type_souhait = 'INTERDICTION'
AND actif = TRUE
LIMIT 1"
);
$forbidden->execute(['agent_id' => $agentId, 'structure_id' => $team['id_structure']]);
if ($forbidden->fetchColumn() !== false) {
throw new \RuntimeException('Cet agent a une interdiction active pour ce lieu.');
}
// Les contraintes médicales et thérapeutiques restent prioritaires, mais
// l’affectation n’est pas bloquée automatiquement : elles sont signalées
// dans les contrôles de l’équipe pour validation humaine.
$stmt = $this->pdo->prepare(
"INSERT INTO equipe_pta_membre (id_equipe, id_agent, fonction_equipe, commentaire)
VALUES (:id_equipe, :id_agent, :fonction_equipe, :commentaire)
ON DUPLICATE KEY UPDATE
fonction_equipe = VALUES(fonction_equipe),
commentaire = VALUES(commentaire)"
);
$stmt->execute([
'id_equipe' => $teamId,
'id_agent' => $agentId,
'fonction_equipe' => $function,
'commentaire' => $comment,
]);
}
/**
* Retire un agent d'une équipe.
*
* @return bool false s'il n'en faisait pas partie.
*/
public function removeMember(int $teamId, int $agentId): bool
{
$stmt = $this->pdo->prepare(
'DELETE FROM equipe_pta_membre WHERE id_equipe = :team_id AND id_agent = :agent_id'
);
$stmt->execute(['team_id' => $teamId, 'agent_id' => $agentId]);
return $stmt->rowCount() > 0;
}
/**
* Change le statut d'une équipe. La valeur est contrôlée par le contrôleur ;
* aucune transition n'est imposée, y compris un retour en arrière.
*/
public function setStatus(int $teamId, string $status): void
{
$stmt = $this->pdo->prepare('UPDATE equipe_pta SET statut = :statut WHERE id_equipe = :id');
$stmt->execute(['statut' => $status, 'id' => $teamId]);
}
/**
* Membres d'une équipe, enrichis de tout ce dont les contrôles ont besoin :
* diplôme, poste, lieu de rattachement, souhait exprimé pour ce lieu, et
* nombre de contraintes actives sur la période de l'équipe (sous-requête
* corrélée, calculée pour la seule fenêtre de dates de l'équipe).
*
* Le FIELD() ordonne par fonction — responsable, animation, restauration —
* plutôt qu'alphabétiquement : c'est l'ordre de lecture d'un trombinoscope.
*
* @return array<int, array<string, mixed>>
*/
private function members(int $teamId): array
{
$stmt = $this->pdo->prepare(
"SELECT em.id_agent, em.fonction_equipe, em.commentaire,
a.matricule, a.nom, a.prenom, a.est_diplome,
a.diplome_libelle, a.adresse, a.id_structure,
po.code AS poste_code, po.libelle AS poste_libelle,
s.nom AS structure_principale_nom,
sw.type_souhait, sw.priorite, sw.distance_km,
(SELECT COUNT(*)
FROM agent_contrainte ac
WHERE ac.id_agent = a.id_agent
AND ac.actif = TRUE
AND ac.date_debut <= e.date_fin
AND COALESCE(ac.date_fin, '9999-12-31') >= e.date_debut) AS contraintes_actives
FROM equipe_pta_membre em
JOIN agent a ON a.id_agent = em.id_agent
LEFT JOIN poste_agent po ON po.id_poste = a.id_poste
LEFT JOIN structure s ON s.id_structure = a.id_structure
LEFT JOIN equipe_pta e ON e.id_equipe = em.id_equipe
LEFT JOIN agent_structure_souhait sw
ON sw.id_agent = a.id_agent
AND sw.id_structure = e.id_structure
AND sw.actif = TRUE
WHERE em.id_equipe = :team_id
ORDER BY FIELD(em.fonction_equipe, 'RESPONSABLE', 'ANIMATION', 'RESTAURATION_ENTRETIEN'), a.nom, a.prenom"
);
$stmt->execute(['team_id' => $teamId]);
return $stmt->fetchAll();
}
/**
* Contrôles d'une équipe.
*
* Deux ERREUR (effectif insuffisant, taux de diplômés sous le seuil), deux
* ALERTE (aucun responsable identifié, membre sous contrainte médicale) et
* une INFO signalant qu'une préférence de l'agent est satisfaite. Un INFO
* « conforme » est ajouté si rien n'a été relevé, pour distinguer l'absence
* d'anomalie d'une absence de contrôle.
*
* Le taux de diplômés est calculé sur les agents AFFECTÉS, pas sur les
* agents requis : c'est la composition réelle de l'équipe qui compte.
*
* @param array<string, mixed> $team
* @param array<int, array<string, mixed>> $members
*
* @return array<int, array<string, string>>
*/
private function controls(array $team, array $members): array
{
$controls = [];
$required = (int) $team['agents_requis'];
$assigned = (int) $team['agents_affectes'];
if ($assigned < $required) {
$controls[] = [
'niveau' => 'ERREUR',
'code' => 'ENCADREMENT_INSUFFISANT',
'message' => sprintf('%d agent(s) affecté(s) pour %d requis.', $assigned, $required),
];
}
$qualified = (int) $team['agents_diplomes'];
$qualifiedRate = $assigned > 0 ? round(($qualified / $assigned) * 100, 2) : 0;
if ($qualifiedRate < (float) $team['pourcentage_diplomes_min']) {
$controls[] = [
'niveau' => 'ERREUR',
'code' => 'QUALIFICATION_INSUFFISANTE',
'message' => sprintf('%.2f %% d’agents diplômés pour un minimum paramétré à %.2f %%.', $qualifiedRate, (float) $team['pourcentage_diplomes_min']),
];
}
if ((int) $team['responsables'] < 1) {
$controls[] = [
'niveau' => 'ALERTE',
'code' => 'RESPONSABLE_MANQUANT',
'message' => 'Aucun responsable n’est identifié dans l’équipe.',
];
}
foreach ($members as $member) {
if ((int) ($member['contraintes_actives'] ?? 0) > 0) {
$controls[] = [
'niveau' => 'ALERTE',
'code' => 'CONTRAINTE_PRIORITAIRE',
'message' => sprintf('%s %s possède une préconisation médicale ou un temps partiel thérapeutique actif sur la période.', $member['prenom'], $member['nom']),
];
}
if (($member['type_souhait'] ?? null) === 'PREFERENCE') {
$controls[] = [
'niveau' => 'INFO',
'code' => 'PREFERENCE_RESPECTEE',
'message' => sprintf('%s %s a exprimé une préférence pour ce lieu.', $member['prenom'], $member['nom']),
];
}
}
if ($controls === []) {
$controls[] = ['niveau' => 'INFO', 'code' => 'OK', 'message' => 'Équipe conforme aux paramètres enregistrés.'];
}
return $controls;
}
/**
* Lieu et dates d'une équipe — lecture légère, sans les agrégats de all().
*
* @return array<string, mixed>|null
*/
private function teamDates(int $teamId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id_structure, date_debut, date_fin FROM equipe_pta WHERE id_equipe = :id LIMIT 1'
);
$stmt->execute(['id' => $teamId]);
return $stmt->fetch() ?: null;
}
}