This commit is contained in:
PTA
2026-08-20 17:00:06 +02:00
parent 5d8c0993a6
commit 94e7f09eb4
58 changed files with 5605 additions and 615 deletions

View File

@@ -46,6 +46,149 @@ final class AgentModel extends BaseModel
return self::hasTrainingRight($agent) ? self::ENVELOPPE_FORMATION_MINUTES : 0;
}
/**
* Bornes du contrat, telles qu'elles servent aux contrôles d'affectation.
*
* Les deux bornes sont FACULTATIVES et n'ont pas le même sens quand elles
* manquent :
* - pas de date de début : contrat d'origine inconnue (fiche ancienne), on
* n'oppose alors aucune borne basse plutôt que d'inventer une date ;
* - pas de date de fin : contrat à durée indéterminée, la borne haute
* n'existe pas.
* Dans les deux cas, l'absence ouvre — elle ne ferme jamais.
*
* @param array<string, mixed> $agent Fiche agent, telle que renvoyée par
* findActive() ou allActive().
*
* @return array{0: string|null, 1: string|null} [début, fin] au format AAAA-MM-JJ.
*/
public static function contractRange(array $agent): array
{
$debut = trim((string) ($agent['date_debut_contrat'] ?? ''));
$fin = trim((string) ($agent['date_fin_contrat'] ?? ''));
return [$debut !== '' ? $debut : null, $fin !== '' ? $fin : null];
}
/**
* Vrai si la date tombe dans les bornes du contrat.
*
* Comparaison de chaînes « AAAA-MM-JJ » : ce format se compare
* lexicographiquement dans le même ordre que chronologiquement, ce qui évite
* de construire deux objets DateTime par créneau contrôlé.
*/
public static function contractCoversDate(array $agent, string $date): bool
{
[$debut, $fin] = self::contractRange($agent);
if ($debut !== null && $date < $debut) {
return false;
}
return !($fin !== null && $date > $fin);
}
/**
* Filtre les dates qui tombent hors contrat, dédoublonnées et triées.
*
* @param array<int, string> $dates
*
* @return array<int, string>
*/
public static function datesOutsideContract(array $agent, array $dates): array
{
$hors = [];
foreach ($dates as $date) {
$date = trim((string) $date);
if ($date !== '' && !self::contractCoversDate($agent, $date)) {
$hors[$date] = true;
}
}
$hors = array_keys($hors);
sort($hors);
return $hors;
}
/** « du 01/09/2026 au 31/08/2027 », « à partir du 01/09/2026 », « jusqu'au … ». */
public static function contractPeriodLabel(array $agent): string
{
[$debut, $fin] = self::contractRange($agent);
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
if ($debut !== null && $fin !== null) {
return sprintf('du %s au %s', $lisible($debut), $lisible($fin));
}
if ($debut !== null) {
return sprintf('à partir du %s', $lisible($debut));
}
if ($fin !== null) {
return sprintf('jusqu’au %s', $lisible($fin));
}
return 'sans date de contrat renseignée';
}
/**
* Message de refus commun à tous les points d'écriture, pour que
* l'utilisateur lise la même phrase quel que soit l'écran qui l'a produit.
*
* @param array<int, string> $datesHorsContrat
*/
public static function contractViolationMessage(array $agent, array $datesHorsContrat): string
{
$lisible = static fn(string $date): string => implode('/', array_reverse(explode('-', $date)));
$exemples = array_map($lisible, array_slice($datesHorsContrat, 0, 3));
if (count($datesHorsContrat) > 3) {
$exemples[] = sprintf('et %d autre(s)', count($datesHorsContrat) - 3);
}
return sprintf(
'%s %s n’est pas sous contrat à cette période : son contrat court %s. Date(s) refusée(s) : %s.',
(string) ($agent['prenom'] ?? ''),
(string) ($agent['nom'] ?? ''),
self::contractPeriodLabel($agent),
implode(', ', $exemples)
);
}
/**
* Créneaux déjà planifiés en dehors d'une fenêtre de contrat.
*
* Sert au contrôle MIROIR de l'affectation : raccourcir un contrat ne doit
* pas laisser derrière lui des affectations orphelines. Interroge donc les
* créneaux, pas les plannings — un planning peut être à cheval sur la borne.
*
* @return array{nombre: int, premiere: string|null, derniere: string|null}
*/
public function entriesOutsideContract(int $agentId, ?string $debut, ?string $fin): array
{
$stmt = $this->pdo->prepare(
'SELECT COUNT(*) AS nombre, MIN(c.date_jour) AS premiere, MAX(c.date_jour) AS derniere
FROM creneau_horaire c
JOIN planning p ON p.id_planning = c.id_planning
WHERE p.id_agent = :agent_id
AND ((:debut IS NOT NULL AND c.date_jour < :debut_compare)
OR (:fin IS NOT NULL AND c.date_jour > :fin_compare))'
);
// Chaque paramètre nommé est répété sous deux noms : PDO tourne sans
// émulation, un même nom ne peut pas apparaître deux fois.
$stmt->execute([
'agent_id' => $agentId,
'debut' => $debut,
'debut_compare' => $debut,
'fin' => $fin,
'fin_compare' => $fin,
]);
$ligne = $stmt->fetch() ?: [];
return [
'nombre' => (int) ($ligne['nombre'] ?? 0),
'premiere' => $ligne['premiere'] ?? null,
'derniere' => $ligne['derniere'] ?? null,
];
}
/**
* Projection commune à toutes les lectures d'agent, complétée par la clause
* WHERE de chaque appelant.
@@ -375,6 +518,10 @@ final class AgentModel extends BaseModel
* bindValue() explicite ici (et non un tableau passé à execute()) car
* `structure_id` peut être NULL et exige PDO::PARAM_NULL.
*
* Le nom et le prénom font partie des champs modifiables depuis la V7.13 ;
* le MATRICULE, lui, ne l'est jamais : c'est la clé de rapprochement avec
* les outils RH, et les plannings comme les PDF déjà produits s'y réfèrent.
*
* @param array<string, mixed> $data Champs déjà validés par le contrôleur.
*
* @throws \RuntimeException Si l'agent n'existe pas.
@@ -395,7 +542,9 @@ final class AgentModel extends BaseModel
$update = $this->pdo->prepare(
'UPDATE agent
SET email = :email,
SET nom = :nom,
prenom = :prenom,
email = :email,
telephone = :telephone,
adresse = :adresse,
est_diplome = :est_diplome,
@@ -410,6 +559,8 @@ final class AgentModel extends BaseModel
WHERE id_agent = :agent_id'
);
$update->bindValue(':agent_id', $agentId, PDO::PARAM_INT);
$update->bindValue(':nom', $data['nom']);
$update->bindValue(':prenom', $data['prenom']);
$update->bindValue(':email', $data['email']);
$update->bindValue(':telephone', $data['telephone']);
$update->bindValue(':adresse', $data['adresse']);