308 lines
13 KiB
PHP
308 lines
13 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Models;
|
||
|
||
use DateTimeImmutable;
|
||
use InvalidArgumentException;
|
||
|
||
/**
|
||
* Suivi du quota PTA sur l'année CIVILE.
|
||
*
|
||
* Le PTA est planifié du 1er janvier au 31 décembre de l'année concernée.
|
||
* La date de début du contrat reste une information RH utile, mais elle ne sert
|
||
* plus d'ancre pour décaler la période annuelle du quota.
|
||
*/
|
||
final class QuotaModel extends BaseModel
|
||
{
|
||
/**
|
||
* Les 1 607 h sont un volume NET : la formule légale a déjà retranché les
|
||
* week-ends, les 25 jours de congés payés et les 8 jours fériés moyens
|
||
* (365 - 104 - 25 - 8 = 228 j × 7 h = 1 596 h, arrondies à 1 600 h, plus
|
||
* les 7 h de la journée de solidarité).
|
||
*
|
||
* Conséquence : ni un jour de congé payé, ni un jour férié ne s'impute au
|
||
* quota. Les imputer reviendrait à les décompter une seconde fois. Seul le
|
||
* travail effectif alimente le compteur, via `compte_dans_quota`.
|
||
*/
|
||
public const REFERENCE_MINUTES = 1607 * 60;
|
||
|
||
/**
|
||
* Crée la ligne de quota annuel d'un agent, proratisée par sa quotité.
|
||
*
|
||
* Appelé à la création d'un agent (AgentModel::createWithDefaults()). Le
|
||
* quota cible est FIGÉ à ce moment-là : modifier ensuite la quotité de
|
||
* l'agent ne recalcule pas rétroactivement l'année en cours, ce qui
|
||
* fausserait un suivi déjà entamé.
|
||
*
|
||
* @param float $rate Quotité en pourcentage (100 = temps plein).
|
||
*/
|
||
public function createForAgent(int $agentId, int $year, float $rate): void
|
||
{
|
||
$target = (int) round(self::REFERENCE_MINUTES * ($rate / 100));
|
||
$stmt = $this->pdo->prepare(
|
||
'INSERT INTO quota_agent_annuel (
|
||
id_agent, annee, quotite_travail, quota_reference_minutes, quota_cible_minutes, commentaire
|
||
) VALUES (
|
||
:agent_id, :annee, :quotite, :quota_reference, :quota_cible, :commentaire
|
||
)'
|
||
);
|
||
$stmt->execute([
|
||
'agent_id' => $agentId,
|
||
'annee' => $year,
|
||
'quotite' => $rate,
|
||
'quota_reference' => self::REFERENCE_MINUTES,
|
||
'quota_cible' => $target,
|
||
'commentaire' => 'Quota PTA de référence pour l’année civile',
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Retourne le quota de l'année civile contenant la date fournie.
|
||
*
|
||
* Exemple : une date de contexte au 15/09/2026 renvoie toujours la période
|
||
* 01/01/2026 -> 31/12/2026, quelle que soit la date de début du contrat.
|
||
*
|
||
* Les minutes VALIDÉES et les minutes en BROUILLON sont comptées
|
||
* séparément mais s'additionnent toutes deux dans `minutes_affectees` :
|
||
* un brouillon consomme déjà le quota du point de vue de la planification,
|
||
* la distinction ne sert qu'à indiquer ce qui est acquis.
|
||
*
|
||
* Le filtre `m.compte_dans_quota = TRUE` est le point de bascule métier :
|
||
* CONGE, AUTRE_ABSENCE et JNT sont ainsi exclus (voir REFERENCE_MINUTES).
|
||
*
|
||
* `minutes_restantes` peut être NÉGATIF — c'est un dépassement, signalé en
|
||
* ALERTE à la validation, jamais bloqué.
|
||
*
|
||
* @return array<string, mixed> Période de référence enrichie des compteurs.
|
||
*/
|
||
public function forDate(int $agentId, string $contextDate): array
|
||
{
|
||
$period = $this->referencePeriod($agentId, $contextDate);
|
||
$quota = $this->quotaProfile($agentId, (int) $period['annee_periode']);
|
||
|
||
$usedStmt = $this->pdo->prepare(
|
||
"SELECT
|
||
COALESCE(SUM(CASE WHEN p.statut = 'VALIDE' THEN
|
||
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
|
||
ELSE 0 END), 0) AS minutes_valides,
|
||
COALESCE(SUM(CASE WHEN p.statut = 'BROUILLON' THEN
|
||
TIMESTAMPDIFF(MINUTE, CONCAT(c.date_jour, ' ', c.heure_debut), CONCAT(c.date_jour, ' ', c.heure_fin))
|
||
ELSE 0 END), 0) AS minutes_brouillon
|
||
FROM planning p
|
||
INNER JOIN creneau_horaire c ON c.id_planning = p.id_planning
|
||
INNER JOIN motif_planning m ON m.id_motif = c.id_motif
|
||
WHERE p.id_agent = :agent_id
|
||
AND c.date_jour BETWEEN :date_debut AND :date_fin
|
||
AND m.compte_dans_quota = TRUE"
|
||
);
|
||
$usedStmt->execute([
|
||
'agent_id' => $agentId,
|
||
'date_debut' => $period['date_debut_periode'],
|
||
'date_fin' => $period['date_fin_periode'],
|
||
]);
|
||
$used = $usedStmt->fetch() ?: ['minutes_valides' => 0, 'minutes_brouillon' => 0];
|
||
|
||
$validated = (int) $used['minutes_valides'];
|
||
$draft = (int) $used['minutes_brouillon'];
|
||
$target = (int) $quota['quota_cible_minutes'];
|
||
$allocated = $validated + $draft;
|
||
|
||
return array_merge($period, [
|
||
'annee' => (int) $period['annee_periode'],
|
||
'quotite_travail' => (float) $quota['quotite_travail'],
|
||
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
|
||
'quota_cible_minutes' => $target,
|
||
'quota_source_annee' => (int) $quota['quota_source_annee'],
|
||
'minutes_valides' => $validated,
|
||
'minutes_brouillon' => $draft,
|
||
'minutes_affectees' => $allocated,
|
||
'minutes_restantes' => $target - $allocated,
|
||
'taux_consommation' => $target > 0 ? round(($allocated / $target) * 100, 2) : 0,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Idem forDate(), avec les libellés lisibles ajoutés par enrich().
|
||
* C'est cette variante que consomment tous les endpoints JSON.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function forDateForApi(int $agentId, string $contextDate): array
|
||
{
|
||
return $this->enrich($this->forDate($agentId, $contextDate));
|
||
}
|
||
|
||
/**
|
||
* Quota d'une année entière. Le 1er janvier suffit comme point d'entrée,
|
||
* la période étant de toute façon l'année civile complète.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function annual(int $agentId, int $year): array
|
||
{
|
||
return $this->forDate($agentId, sprintf('%04d-01-01', $year));
|
||
}
|
||
|
||
/**
|
||
* Idem annual(), enrichi des libellés.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function annualForApi(int $agentId, int $year): array
|
||
{
|
||
return $this->forDateForApi($agentId, sprintf('%04d-01-01', $year));
|
||
}
|
||
|
||
/**
|
||
* Période de référence PTA : toujours l'année civile.
|
||
* La date de contrat est retournée uniquement à titre informatif.
|
||
*
|
||
* Les clés `periode_contractuelle`, `periode_civile` et
|
||
* `date_contrat_manquante` sont des VESTIGES d'une version où la période
|
||
* pouvait être ancrée sur la date de contrat. Elles sont figées
|
||
* (false / true / false) et conservées pour ne pas casser les écrans qui les
|
||
* lisent encore. Ne pas construire de nouvelle logique dessus.
|
||
*
|
||
* @return array<string, mixed>
|
||
*
|
||
* @throws InvalidArgumentException Si la date de contexte est malformée.
|
||
*/
|
||
public function referencePeriod(int $agentId, string $contextDate): array
|
||
{
|
||
$context = DateTimeImmutable::createFromFormat('!Y-m-d', $contextDate);
|
||
if (!$context || $context->format('Y-m-d') !== $contextDate) {
|
||
throw new InvalidArgumentException('Date de référence du quota invalide.');
|
||
}
|
||
|
||
$stmt = $this->pdo->prepare(
|
||
'SELECT date_debut_contrat FROM agent WHERE id_agent = :agent_id LIMIT 1'
|
||
);
|
||
$stmt->execute(['agent_id' => $agentId]);
|
||
$contractStartValue = $stmt->fetchColumn();
|
||
|
||
$year = (int) $context->format('Y');
|
||
$start = new DateTimeImmutable(sprintf('%04d-01-01', $year));
|
||
$end = new DateTimeImmutable(sprintf('%04d-12-31', $year));
|
||
|
||
return [
|
||
'date_reference' => $context->format('Y-m-d'),
|
||
'date_debut_contrat' => $contractStartValue ? (string) $contractStartValue : null,
|
||
'date_debut_periode' => $start->format('Y-m-d'),
|
||
'date_fin_periode' => $end->format('Y-m-d'),
|
||
'annee_periode' => $year,
|
||
'periode_libelle' => sprintf('année civile %d · du %s au %s', $year, $start->format('d/m/Y'), $end->format('d/m/Y')),
|
||
'periode_contractuelle' => false,
|
||
'periode_civile' => true,
|
||
// Conservé pour compatibilité avec les anciens écrans : la date de
|
||
// contrat n'est plus requise pour calculer la période du quota.
|
||
'date_contrat_manquante' => false,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Ajoute les versions lisibles des compteurs à un quota brut.
|
||
*
|
||
* Les clés en minutes restent présentes : le JavaScript calcule sur les
|
||
* minutes et n'affiche que les libellés.
|
||
*
|
||
* @param array<string, mixed> $quota
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
public function enrich(array $quota): array
|
||
{
|
||
return array_merge($quota, [
|
||
'quota_cible' => $this->formatMinutes((int) $quota['quota_cible_minutes']),
|
||
'valides' => $this->formatMinutes((int) $quota['minutes_valides']),
|
||
'brouillons' => $this->formatMinutes((int) $quota['minutes_brouillon']),
|
||
'affectees' => $this->formatMinutes((int) $quota['minutes_affectees']),
|
||
'restantes' => $this->formatMinutes((int) $quota['minutes_restantes']),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Met en forme une durée en minutes sous ses trois représentations.
|
||
*
|
||
* Gère les valeurs NÉGATIVES (dépassement de quota) : le signe est extrait
|
||
* avant la division, sans quoi intdiv() et le modulo produiraient un libellé
|
||
* incohérent du type « -1 h -30 ».
|
||
*
|
||
* @return array{minutes: int, heures_decimales: float, libelle: string}
|
||
* Ex. : 90 → « 1 h 30 » ; -90 → « -1 h 30 ».
|
||
*/
|
||
public function formatMinutes(int $minutes): array
|
||
{
|
||
$sign = $minutes < 0 ? -1 : 1;
|
||
$absolute = abs($minutes);
|
||
$hours = intdiv($absolute, 60);
|
||
$mins = $absolute % 60;
|
||
|
||
return [
|
||
'minutes' => $minutes,
|
||
'heures_decimales' => round($minutes / 60, 2),
|
||
'libelle' => ($sign < 0 ? '-' : '') . $hours . ' h ' . str_pad((string) $mins, 2, '0', STR_PAD_LEFT),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Retrouve la ligne de quota la plus pertinente pour une année donnée.
|
||
*
|
||
* Le tri du ORDER BY applique une règle de repli en trois temps :
|
||
* 1. l'année demandée exactement ;
|
||
* 2. sinon l'année ANTÉRIEURE la plus proche (la quotité connue la plus
|
||
* récente reste la meilleure estimation) ;
|
||
* 3. sinon l'année POSTÉRIEURE la plus proche.
|
||
*
|
||
* Sans aucune ligne — agent créé avant la mise en place des quotas — on
|
||
* retombe sur un temps plein théorique plutôt que sur un quota nul, qui
|
||
* afficherait un dépassement à la première heure planifiée.
|
||
*
|
||
* `quota_source_annee` indique quelle année a réellement servi, pour que
|
||
* l'interface puisse signaler un quota emprunté à une autre année.
|
||
*
|
||
* @return array{quotite_travail: float, quota_reference_minutes: int, quota_cible_minutes: int, quota_source_annee: int}
|
||
*/
|
||
private function quotaProfile(int $agentId, int $year): array
|
||
{
|
||
$stmt = $this->pdo->prepare(
|
||
"SELECT annee, quotite_travail, quota_reference_minutes, quota_cible_minutes
|
||
FROM quota_agent_annuel
|
||
WHERE id_agent = :agent_id
|
||
ORDER BY CASE
|
||
WHEN annee = :year_exact THEN 0
|
||
WHEN annee < :year_past THEN 1
|
||
ELSE 2
|
||
END,
|
||
CASE WHEN annee <= :year_latest THEN annee END DESC,
|
||
CASE WHEN annee > :year_future THEN annee END ASC
|
||
LIMIT 1"
|
||
);
|
||
$stmt->execute([
|
||
'agent_id' => $agentId,
|
||
'year_exact' => $year,
|
||
'year_past' => $year,
|
||
'year_latest' => $year,
|
||
'year_future' => $year,
|
||
]);
|
||
$quota = $stmt->fetch();
|
||
|
||
if (!$quota) {
|
||
return [
|
||
'quotite_travail' => 100.00,
|
||
'quota_reference_minutes' => self::REFERENCE_MINUTES,
|
||
'quota_cible_minutes' => self::REFERENCE_MINUTES,
|
||
'quota_source_annee' => $year,
|
||
];
|
||
}
|
||
|
||
return [
|
||
'quotite_travail' => (float) $quota['quotite_travail'],
|
||
'quota_reference_minutes' => (int) $quota['quota_reference_minutes'],
|
||
'quota_cible_minutes' => (int) $quota['quota_cible_minutes'],
|
||
'quota_source_annee' => (int) $quota['annee'],
|
||
];
|
||
}
|
||
}
|